Skip to content

Commit b704a92

Browse files
authored
Argument normalizer closes #987 (#988)
* argument normalizer Signed-off-by: Mihai Criveti <[email protected]> * argument normalizer Signed-off-by: Mihai Criveti <[email protected]> * disable opa by default Signed-off-by: Mihai Criveti <[email protected]> * plugins Signed-off-by: Mihai Criveti <[email protected]> * plugins retry Signed-off-by: Mihai Criveti <[email protected]> --------- Signed-off-by: Mihai Criveti <[email protected]>
1 parent facb02c commit b704a92

File tree

12 files changed

+781
-29
lines changed

12 files changed

+781
-29
lines changed

llms/mcpgateway.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ MCP Gateway: Full Project Overview
4848
- Hooks (production): `prompt_pre_fetch`, `prompt_post_fetch`, `tool_pre_invoke`, `tool_post_invoke`, `resource_pre_fetch`, `resource_post_fetch`.
4949
- Configuration: `plugins/config.yaml` with `plugins`, `plugin_dirs`, `plugin_settings`.
5050
- Modes: `enforce | enforce_ignore_error | permissive | disabled`; priority ascending.
51-
- Built‑ins: PII filter, regex search/replace, denylist, resource filter; OPA external example.
51+
- Built‑ins: Argument Normalizer, PII filter, regex search/replace, denylist, resource filter; OPA external example.
52+
- Default ordering (lower runs first): Argument Normalizer (40) → PII Filter (50) → Resource Filter (75) → Deny/Regex (100+/150). This ensures inputs are stabilized before detection/redaction.
5253
- Authoring helpers:
5354
- Bootstrap templates: `mcpplugins bootstrap --destination <dir> --type native|external`
5455
- External runtime default: Streamable HTTP at `http://localhost:8000/mcp`
@@ -114,4 +115,3 @@ MCP Gateway: Full Project Overview
114115
- Conventional Commits; sign off (`git commit -s`); link issues.
115116
- Include tests/docs for behavior changes. Keep code typed (Py ≥ 3.11), formatted, and lint‑clean.
116117
- Do not mention competitive assistants in PRs; avoid effort estimates.
117-

llms/plugins-llms.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,11 @@ Plugins: How They Work in MCP Context Forge
132132
- Enable framework in gateway: `.env` must set `PLUGINS_ENABLED=true` and optionally `PLUGIN_CONFIG_FILE=plugins/config.yaml`.
133133

134134
**Built‑in Plugins (Examples)**
135+
- `ArgumentNormalizer` (`plugins/argument_normalizer/argument_normalizer.py`)
136+
- Hooks: prompt pre, tool pre
137+
- Normalizes Unicode (NFC/NFD/NFKC/NFKD), trims/collapses whitespace, optional casing, numeric date strings to ISO `YYYY-MM-DD`, and numbers to canonical form (dot decimal, no thousands). Per-field overrides via regex.
138+
- Config: `enable_unicode`, `unicode_form`, `remove_control_chars`, `enable_whitespace`, `trim`, `collapse_internal`, `normalize_newlines`, `collapse_blank_lines`, `enable_casing`, `case_strategy`, `enable_dates`, `day_first`, `year_first`, `enable_numbers`, `decimal_detection`, `field_overrides`.
139+
- Ordering: place before PII filter (lower priority value) so PII patterns see stabilized inputs. Recommended mode: `permissive`.
135140
- `PIIFilterPlugin` (`plugins/pii_filter/pii_filter.py`)
136141
- Hooks: prompt pre/post, tool pre/post
137142
- Detects and masks PII (SSN, credit card, email, phone, IP, keys, etc.) via regex; supports strategies: redact/partial/hash/tokenize/remove
@@ -229,7 +234,7 @@ async function toolPreInvoke({ payload, context }: any) {
229234

230235
**Where to Look in the Code**
231236
- Framework: `mcpgateway/plugins/framework/{base.py,models.py,manager.py,registry.py,loader/,external/mcp/client.py}`
232-
- Built-in plugins: `plugins/{pii_filter,regex_filter,deny_filter,resource_filter}`
237+
- Built-in plugins: `plugins/{argument_normalizer,pii_filter,regex_filter,deny_filter,resource_filter}`
233238
- Gateway config: `plugins/config.yaml`
234239
- Templates and CLI: `plugin_templates/` and CLI `mcpplugins` in `mcpgateway/plugins/tools/cli.py`; prompts handled by `copier.yml`.
235240

mcpgateway/main.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,12 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
324324
yield
325325
except Exception as e:
326326
logger.error(f"Error during startup: {str(e)}")
327+
# For plugin errors, exit cleanly without stack trace spam
328+
if "Plugin initialization failed" in str(e):
329+
# Suppress uvicorn error logging for clean exit
330+
import logging
331+
logging.getLogger("uvicorn.error").setLevel(logging.CRITICAL)
332+
raise SystemExit(1)
327333
raise
328334
finally:
329335
# Shutdown plugin manager

mcpgateway/plugins/framework/external/mcp/client.py

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -135,29 +135,54 @@ async def __connect_to_stdio_server(self, server_script_path: str) -> None:
135135
raise PluginError(error=convert_exception_to_error(e, plugin_name=self.name))
136136

137137
async def __connect_to_http_server(self, uri: str) -> None:
138-
"""Connect to an MCP plugin server via streamable http.
138+
"""Connect to an MCP plugin server via streamable http with retry logic.
139139
140140
Args:
141141
uri: the URI of the mcp plugin server.
142142
143143
Raises:
144-
PluginError: if there is an external connection error.
144+
PluginError: if there is an external connection error after all retries.
145145
"""
146-
147-
try:
148-
http_transport = await self._exit_stack.enter_async_context(streamablehttp_client(uri))
149-
self._http, self._write, _ = http_transport
150-
self._session = await self._exit_stack.enter_async_context(ClientSession(self._http, self._write))
151-
152-
await self._session.initialize()
153-
154-
# List available tools
155-
response = await self._session.list_tools()
156-
tools = response.tools
157-
logger.info("\nConnected to plugin MCP (http) server with tools: %s", " ".join([tool.name for tool in tools]))
158-
except Exception as e:
159-
logger.exception(e)
160-
raise PluginError(error=convert_exception_to_error(e, plugin_name=self.name))
146+
max_retries = 3
147+
base_delay = 1.0
148+
149+
for attempt in range(max_retries):
150+
logger.info(f"Connecting to external plugin server: {uri} (attempt {attempt + 1}/{max_retries})")
151+
152+
try:
153+
# Create a fresh exit stack for each attempt
154+
async with AsyncExitStack() as temp_stack:
155+
http_transport = await temp_stack.enter_async_context(streamablehttp_client(uri))
156+
http_client, write_func, _ = http_transport
157+
session = await temp_stack.enter_async_context(ClientSession(http_client, write_func))
158+
159+
await session.initialize()
160+
161+
# List available tools
162+
response = await session.list_tools()
163+
tools = response.tools
164+
logger.info("Successfully connected to plugin MCP server with tools: %s", " ".join([tool.name for tool in tools]))
165+
166+
# Success! Now move to the main exit stack
167+
self._http = await self._exit_stack.enter_async_context(streamablehttp_client(uri))
168+
self._http, self._write, _ = self._http
169+
self._session = await self._exit_stack.enter_async_context(ClientSession(self._http, self._write))
170+
await self._session.initialize()
171+
return
172+
173+
except Exception as e:
174+
logger.warning(f"Connection attempt {attempt + 1}/{max_retries} failed: {e}")
175+
176+
if attempt == max_retries - 1:
177+
# Final attempt failed
178+
error_msg = f"External plugin '{self.name}' connection failed after {max_retries} attempts: {uri} is not reachable. Please ensure the MCP server is running."
179+
logger.error(error_msg)
180+
raise PluginError(error=PluginErrorModel(message=error_msg, plugin_name=self.name))
181+
182+
# Wait before retry
183+
delay = base_delay * (2**attempt)
184+
logger.info(f"Retrying in {delay}s...")
185+
await asyncio.sleep(delay)
161186

162187
async def __invoke_hook(self, payload_result_model: Type[P], hook_type: HookType, payload: BaseModel, context: PluginContext) -> P:
163188
"""Invoke an external plugin hook using the MCP protocol.

mcpgateway/plugins/framework/manager.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -592,8 +592,10 @@ async def initialize(self) -> None:
592592
else:
593593
raise ValueError(f"Unable to instantiate plugin: {plugin_config.name}")
594594
except Exception as e:
595-
logger.error(f"Failed to load plugin {plugin_config.name}: {str(e)}")
596-
raise ValueError(f"Unable to register and initialize plugin: {plugin_config.name}") from e
595+
# Clean error message without stack trace spam
596+
logger.error(f"Failed to load plugin '{plugin_config.name}': {str(e)}")
597+
# Let it crash gracefully with a clean error
598+
raise RuntimeError(f"Plugin initialization failed: {plugin_config.name} - {str(e)}")
597599
else:
598600
logger.debug(f"Skipping disabled plugin: {plugin_config.name}")
599601

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Argument Normalizer Plugin
2+
3+
Author: Mihai Criveti
4+
5+
Normalizes user/tool arguments before they reach prompts or tools. It improves robustness and predictability by:
6+
- Unicode normalization (NFC/NFD/NFKC/NFKD) and control-char stripping
7+
- Whitespace cleanup (trim, collapse internal whitespace, CRLF→LF, optional blank-line collapse)
8+
- Optional casing strategies (none/lower/upper/title)
9+
- Numeric date normalization to ISO 8601 (`YYYY-MM-DD`) with `day_first`/`year_first`
10+
- Number normalization to canonical format with `.` as decimal separator
11+
12+
## Hooks
13+
- `prompt_pre_fetch`
14+
- `tool_pre_invoke`
15+
16+
## Quick Config Example
17+
Add this entry in `plugins/config.yaml` (already wired by default):
18+
19+
```yaml
20+
- name: "ArgumentNormalizer"
21+
kind: "plugins.argument_normalizer.argument_normalizer.ArgumentNormalizerPlugin"
22+
description: "Normalizes Unicode, whitespace, casing, dates, and numbers in args"
23+
version: "0.1.0"
24+
author: "Mihai Criveti"
25+
hooks: ["prompt_pre_fetch", "tool_pre_invoke"]
26+
tags: ["normalize", "inputs", "whitespace", "unicode", "dates", "numbers"]
27+
mode: "permissive"
28+
priority: 40
29+
conditions: []
30+
config:
31+
enable_unicode: true
32+
unicode_form: "NFC"
33+
remove_control_chars: true
34+
enable_whitespace: true
35+
trim: true
36+
collapse_internal: true
37+
normalize_newlines: true
38+
collapse_blank_lines: false
39+
enable_casing: false
40+
case_strategy: "none"
41+
enable_dates: true
42+
day_first: false
43+
year_first: false
44+
enable_numbers: true
45+
decimal_detection: "auto"
46+
field_overrides: []
47+
```
48+
49+
## Field Overrides
50+
Use `field_overrides` to tailor normalization per-field using regexes that match field paths (e.g. `user.name`, `items[0].title`). Example:
51+
52+
```yaml
53+
config:
54+
field_overrides:
55+
- pattern: "^user\\.name$"
56+
enable_casing: true
57+
case_strategy: "title"
58+
- pattern: "price|amount|total"
59+
enable_numbers: true
60+
decimal_detection: "auto"
61+
- pattern: "^notes$"
62+
collapse_blank_lines: true
63+
```
64+
65+
## Examples
66+
- Input: `" JOHN DOE "` with lower-casing → `"john doe"`
67+
- Input: `"1.234,56 EUR"` with numeric normalization → `"1234.56 EUR"`
68+
- Input: `"Due 31/12/2023"` with `day_first: true` → `"Due 2023-12-31"`
69+
- Input: `"Cafe\u0301"` (combining accent) → `"Café"` (NFC)
70+
71+
## Testing
72+
- Unit tests: `tests/unit/mcpgateway/plugins/plugins/argument_normalizer/test_argument_normalizer.py`
73+
- Doctests embedded in `argument_normalizer.py` (`_normalize_text` docstring)
74+
75+
Run locally:
76+
77+
```bash
78+
pytest -q tests/unit/mcpgateway/plugins/plugins/argument_normalizer/test_argument_normalizer.py
79+
pytest -q --doctest-modules plugins/argument_normalizer/argument_normalizer.py
80+
```
81+
82+
## Notes
83+
- The plugin is non-blocking and only returns modified payloads when changes occur.
84+
- Date parsing is regex-based and conservative; non-numeric formats are left unchanged.
85+
- If both day and month are ≤ 12, `day_first` controls ambiguity.
86+
- Numeric normalization keeps the last decimal separator and strips other thousands separators.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# -*- coding: utf-8 -*-
2+
"""Argument Normalizer plugin package."""

0 commit comments

Comments
 (0)