Plugins: How They Work in MCP Context Forge
- Purpose: Concise, code-grounded guidance for LLMs to reason about, write, and configure plugins in this gateway.
- Scope: In‑process (native) Python plugins and external plugins over MCP (STDIO or Streamable HTTP), unified by a single interface and common hook lifecycle.
Big Picture
- Hybrid model: Runs both self-contained plugins in‑process and external plugins as MCP servers; both implement the same hook interface.
- Lifecycle hooks: Six production hooks cover prompts, tools, and resources:
prompt_pre_fetch,prompt_post_fetch,tool_pre_invoke,tool_post_invoke,resource_pre_fetch,resource_post_fetch. - Sequential execution: Plugins execute in priority order (ascending). Each result can modify payloads or block processing.
- Modes:
enforce,enforce_ignore_error,permissive,disabledcontrol blocking and error behavior. - Context sharing: Per-request GlobalContext plus per-plugin PluginContext with shared state across pre/post pairs; gateway auto-cleans stale contexts.
- Configuration: Single YAML at
plugins/config.yaml(Jinja-enabled). Strict Pydantic validation; per-plugin conditions for selective execution. - Safety: Per-call timeouts, payload size guardrails, error isolation, and audit visibility.
Core Interfaces
- Base class:
mcpgateway.plugins.framework.base.Plugin- Exposes async methods for each hook; plugins override only the hooks they need.
- Properties from config:
.name,.mode,.priority,.hooks,.tags,.conditions.
- Hook payload/result models (Pydantic) in
mcpgateway/plugins/framework/models.py:- Prompt
PromptPrehookPayload(name: str, args: dict[str, str])PromptPosthookPayload(name: str, result: PromptResult)- Results:
PromptPrehookResult,PromptPosthookResult
- Tool
ToolPreInvokePayload(name: str, args: dict[str, Any])ToolPostInvokePayload(name: str, result: Any)- Results:
ToolPreInvokeResult,ToolPostInvokeResult
- Resource
ResourcePreFetchPayload(uri: str, metadata: dict[str, Any])ResourcePostFetchPayload(uri: str, content: Any)- Results:
ResourcePreFetchResult,ResourcePostFetchResult
- Result schema for all hooks:
PluginResult[T]with fieldscontinue_processing: bool = Truemodified_payload: Optional[T](when transforming)violation: Optional[PluginViolation](when blocking or auditing)metadata: dict[str, Any] = {}(accumulates across plugins)
- Prompt
- Violation schema:
PluginViolation(reason, description, code, details); manager injectsviolation.plugin_nameat runtime. - Contexts in
models.pyGlobalContext(request_id, user?, tenant_id?, server_id?, state={}, metadata={})PluginContext(state={}, global_context: GlobalContext, metadata={})with helpersget_state/set_state.
Hook Semantics
prompt_pre_fetch: Before retrieving/rendering a prompt. Typical: validate/transform args; mask PII; may block.prompt_post_fetch: After rendering. Typical: filter/sanitize content; add metadata.tool_pre_invoke: Before executing a tool. Typical: auth/validation; policy checks; arg mutation; may block.tool_post_invoke: After tool returns. Typical: redact outputs; transform result; audit metadata.resource_pre_fetch: Before fetching URI. Typical: protocol/domain checks; metadata injection; may block.resource_post_fetch: After content is fetched. Typical: size checks; redaction; content transformation.
Execution Model
- Ordering: Deterministic, by ascending
priority. Lower runs first. - Conditions: A plugin's
conditionsmust match for the current context to execute it. Fields includeserver_ids,tenant_ids,tools,prompts,resources,user_patterns,content_types. Matching helpers inutils.py. - Modes and blocking:
enforce: If a result setscontinue_processing=False, manager immediately returns a block with the violation.enforce_ignore_error: Enforce violations; errors don't block (manager may continue based on global settings).permissive: Log/report violations; continue.disabled: Loaded but not executed.
- Timeouts and errors:
- Per-plugin timeout (default 30s) enforced via
asyncio.wait_for. - Payload size guardrails (~1MB) for prompt args and results.
- Error isolation: behavior controlled by
plugin_settings.fail_on_plugin_errorand pluginmode.
- Per-plugin timeout (default 30s) enforced via
- Context lifecycle: Manager stores per-request plugin contexts between pre/post hooks and cleans them periodically (every 5m; expire at 1h).
Configuration File (plugins/config.yaml)
- Top-level keys:
plugins: [],plugin_dirs: [],plugin_settings: {}. - Plugin entries (validated by
PluginConfig):name: unique idkind: fully-qualified class path for native, or literalexternalfor MCP pluginsdescription,version,author,tagshooks: any of the six production hooksmode:enforce | enforce_ignore_error | permissive | disabledpriority: int (smaller → earlier)conditions: list of selector blocks (see Execution Model)applied_to(optional): advanced targeting templates for tools/prompts/resources and context extractionconfig: plugin-specific dict (native only; external config lives on the external server)mcp(external only):proto:STDIO | STREAMABLEHTTP | SSEurlfor HTTP-like transports,scriptorcmdfor STDIO,udsfor Streamable HTTP over unix sockets
- Global
plugin_settings:parallel_execution_within_band(reserved)plugin_timeout(seconds)fail_on_plugin_error(bool)enable_plugin_api(bool)plugin_health_check_interval(reserved)
- Jinja support: File is rendered with Jinja;
${env}values can be injected from environment.
External Plugins over MCP
- Client:
ExternalPlugininexternal/mcp/client.pyhandles session, tool calls, merging remote config into local. - Required tool names on server match hook names:
get_plugin_configprompt_pre_fetch,prompt_post_fetchtool_pre_invoke,tool_post_invokeresource_pre_fetch,resource_post_fetch
- Call contract (JSON over MCP):
- Request to each hook:
{ "plugin_name": str, "payload": <HookPayload>, "context": <PluginContext> } - Response expected as JSON text with one of:
{ "result": <PluginResult serialized> }{ "context": <PluginContext serialized> }(to update context){ "error": <PluginErrorModel> }to signal errors
- Request to each hook:
get_plugin_configmust return aPluginConfig-compatible JSON; the gateway merges remote+local with local taking precedence for gateway-owned fields. For external plugins, gateway-sideconfigis disallowed (plugin's own server owns it).- Transports:
STDIO(spawn script/command) orSTREAMABLEHTTP(connect tourl, optionally viauds). - Validation:
scriptmust exist (if absolute) and be.py/.shor executable;urlmust pass security validation.
Authoring Workflow
- CLI:
mcpplugins bootstrap --destination <dir> [--type native|external]creates a project from templates inplugin_templates/.- Native template: Python class extending
Plugin, withplugin-manifest.yaml.jinja, example config, and README. - External template: Full project with runtime config, tests, container build, and MCP server entrypoint.
- Native template: Python class extending
- External plugin dev loop (from Lifecycle docs):
make install-dev(ormake install-editable)- Configure
resources/plugins/config.yamlandresources/runtime/config.yaml make testmake build(containerized MCP server)make start(default Streamable HTTP athttp://localhost:8000/mcp)- Integrate with gateway by adding to gateway's
plugins/config.yaml:- name: "MyFilter" kind: "external" priority: 10 mcp: proto: STREAMABLEHTTP url: http://localhost:8000/mcp # uds: /var/run/mcp-plugin.sock # use UDS instead of TCP # tls: # ca_bundle: /app/certs/plugins/ca.crt # client_cert: /app/certs/plugins/gateway-client.pem
- STDIO alternative:
- name: "MyFilter" kind: "external" priority: 10 mcp: proto: STDIO cmd: ["python", "path/to/server.py"] env: PLUGINS_CONFIG_PATH: "/opt/plugins/config.yaml" cwd: "/opt/plugins" # or: script: path/to/server.py
- Enable framework in gateway:
.envmust setPLUGINS_ENABLED=trueand optionallyPLUGIN_CONFIG_FILE=plugins/config.yaml. To reuse a gateway-wide mTLS client certificate for multiple external plugins, setPLUGINS_MTLS_CA_BUNDLE,PLUGINS_MTLS_CLIENT_CERT, and relatedPLUGINS_MTLS_*variables. Individual plugintlsblocks override these defaults.
Built‑in Plugins (39 plugins in 42 directories)
Security & Filtering:
pii_filter- PII detection and masking (SSN, credit card, email, phone, IP, keys)deny_filter- Denylist word blockingsecrets_detection- Secret/credential detectioncontent_moderation- Content moderationharmful_content_detector- Harmful content detectioncode_safety_linter- Code safety validation
Validation:
schema_guard- Schema validationsql_sanitizer- SQL injection preventionsafe_html_sanitizer- HTML sanitizationfile_type_allowlist- File type restrictionscitation_validator- Citation validationrobots_license_guard- Robots.txt and license compliancesparc_static_validator- Static validationresource_filter- URI/protocol/domain validation, size limits
Data Processing:
argument_normalizer- Unicode, whitespace, casing, date, number normalizationmarkdown_cleaner- Markdown cleanuphtml_to_markdown- HTML to Markdown conversioncode_formatter- Code formattingjson_repair- JSON repair and normalizationaltk_json_processor- ALTK JSON processingai_artifacts_normalizer- AI artifact normalizationtimezone_translator- Timezone conversionsummarizer- Content summarization
Optimization:
cached_tool_result- Tool result cachingresponse_cache_by_prompt- Response cachingcircuit_breaker- Circuit breaker patternretry_with_backoff- Retry logic with backoffrate_limiter- Rate limitingoutput_length_guard- Output length limits
Utilities:
header_injector- HTTP header injectionlicense_header_injector- License header injectionprivacy_notice_injector- Privacy notice injectiontools_telemetry_exporter- Telemetry exportwebhook_notification- Webhook notifications
External Services:
virus_total_checker- VirusTotal integrationurl_reputation- URL reputation checkingwatchdog- Monitoring/watchdogvault- HashiCorp Vault integration
Examples:
examples/- Example plugin templatesexternal/- External plugin examples (OPA policy enforcement)
Key Plugin Examples:
ArgumentNormalizer(plugins/argument_normalizer/argument_normalizer.py)- Hooks: prompt pre, tool pre
- 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. - 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. - Ordering: place before PII filter (lower priority value) so PII patterns see stabilized inputs. Recommended mode:
permissive.
PIIFilterPlugin(plugins/pii_filter/pii_filter.py)- Hooks: prompt pre/post, tool pre/post
- Detects and masks PII (SSN, credit card, email, phone, IP, keys, etc.) via regex; supports strategies: redact/partial/hash/tokenize/remove
- Config: detection toggles,
default_mask_strategy,redaction_text,block_on_detection,log_detections,whitelist_patterns,custom_patterns - Behavior: may block in
enforce, otherwise modifies payload (masked values) and sets metadata
SearchReplacePlugin(plugins/regex_filter/search_replace.py)- Hooks: prompt pre/post, tool pre/post
- Regex search/replace on string fields; config:
words: [{search, replace}, ...]
DenyListPlugin(plugins/deny_filter/deny.py)- Hooks: prompt pre
- Blocks when any denylisted word is found in prompt args; config:
words: []
ResourceFilterPlugin(plugins/resource_filter/resource_filter.py)- Hooks: resource pre/post
- Validates protocol/URI, size limits, domain blocks, content redaction; adds request metadata; config includes
max_content_size,allowed_protocols,blocked_domains,content_filters
- External OPA example (
plugins/external/opa)- Demonstrates external policy enforcement at
tool_pre_invokeby calling an OPA server; showsapplied_tousage to target specific tools and feed policy context.
- Demonstrates external policy enforcement at
Manager and Registry Behavior
PluginManager(singleton)- Loads config via
ConfigLoader(Jinja + YAML); instantiates viaPluginLoader. - Executes per-hook via
PluginExecutor, validates payload size, enforces timeouts, manages contexts, aggregates metadata. - Stores per-request contexts between pre/post and cleans them periodically.
- Loads config via
PluginInstanceRegistry- Registers
PluginRef(wrapping plugins with UUIDs), keeps per-hook lists, returns plugins ordered by priority.
- Registers
Practical Tips for LLM‑Written Plugins
- Keep hook methods pure async and non-blocking; respect timeouts.
- Only set
violationandcontinue_processing=Falseto block; otherwise returncontinue_processing=Truewith optionalmodified_payload. - If you modify the payload, return a fully-formed payload object of the same type; the manager threads it to subsequent plugins.
- Use
context.statefor local plugin data; usecontext.global_context.stateto share across plugins; do not mutateglobal_context.metadatadirectly—prefer adding tocontext.metadataand structured result metadata. - External servers must return exactly one of
result,context, orerrorper call; JSON is passed as string content in MCP responses. - External plugin
get_plugin_configshould advertise hooks, priority, and metadata; the gateway will merge with gateway-side fields and re-validate.
Example: Minimal Native Plugin
from mcpgateway.plugins.framework import Plugin, PluginConfig, PluginContext
from mcpgateway.plugins.framework import PromptPrehookPayload, PromptPrehookResult
from mcpgateway.plugins.framework import PluginViolation
class MyGuard(Plugin):
async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult:
if payload.args and any("forbidden" in v for v in payload.args.values() if isinstance(v, str)):
return PromptPrehookResult(
continue_processing=False,
violation=PluginViolation(
reason="Forbidden content",
description="Blocked by MyGuard",
code="FORBIDDEN",
details={"matched": True},
),
)
return PromptPrehookResult(modified_payload=payload)
Example: Register Native Plugin
plugins:
- name: "MyGuard"
kind: "plugins.my_guard.plugin.MyGuard"
hooks: ["prompt_pre_fetch"]
mode: "enforce"
priority: 100
Example: External Plugin Tool (TypeScript outline)
// Tool name must be one of the hook names, e.g., "tool_pre_invoke"
// The server must also implement "get_plugin_config"
@Tool("tool_pre_invoke")
async function toolPreInvoke({ payload, context }: any) {
// Return { result: PluginResult } as MCP JSON text
// e.g., allow and add metadata
return {
continue_processing: true,
metadata: { checked: true }
};
}
Environment and Enablement
- Enable plugins in gateway
.env:PLUGINS_ENABLED=trueand optionallyPLUGIN_CONFIG_FILE=plugins/config.yaml. - Run gateway:
make dev(reload) ormake serve. - Validate config:
make check-envandmake doctest testfor framework models.
Security and Limits
- Timeouts: Default 30s per hook; tune via
plugin_settings.plugin_timeout. - Size limits: ~1MB for prompt args and rendered results; large payloads raise
PayloadSizeError. - Error isolation: Set
fail_on_plugin_errorfor strict behavior; otherwise errors in permissive plugins don't block. - External validation:
scriptmust exist and end with.py;urlis validated; avoid injecting secrets in YAML—use env vars and Jinja.
Roadmap Hooks (Not Yet Implemented)
- Server lifecycle:
server_pre_register,server_post_register - Authentication:
auth_pre_check,auth_post_check - Federation:
federation_pre_sync,federation_post_sync
Where to Look in the Code
- Framework:
mcpgateway/plugins/framework/{base.py,models.py,manager.py,registry.py,loader/,external/mcp/client.py} - Built-in plugins:
plugins/{argument_normalizer,pii_filter,regex_filter,deny_filter,resource_filter} - Gateway config:
plugins/config.yaml - Templates and CLI:
plugin_templates/and CLImcppluginsinmcpgateway/plugins/tools/cli.py; prompts handled bycookiecutter.json.
Testing Plugins
Code quality & pre-commit (see AGENTS.md for details):
make autoflake isort black pre-commitformats, orders imports, applies autoflake, and runs pre-commit hooks.make pylint flake8runs static analysis; fix findings before committing.make doctest testexecutes doctests then pytest; mirrors CI expectations locally.
Root-level commands:
make testruns unit tests.make doctestruns doctests embedded in framework models and helpers.make htmlcovgenerates HTML coverage atdocs/docs/coverage/index.html.- Use
pytest -k "name"and marks (e.g.,pytest -m "not slow").
Unit test a native plugin (pytest):
import pytest from mcpgateway.plugins.framework import ( HookType, PluginConfig, PluginContext, GlobalContext, PromptPrehookPayload, PromptPrehookResult, ) from plugins.regex_filter.search_replace import SearchReplacePlugin @pytest.mark.asyncio async def test_regex_search_replace_prompt_pre(): cfg = PluginConfig( name="sr", kind="plugins.regex_filter.search_replace.SearchReplacePlugin", hooks=[HookType.PROMPT_PRE_FETCH], priority=100, config={"words": [{"search": "crap", "replace": "crud"}]}, ) plugin = SearchReplacePlugin(cfg) payload = PromptPrehookPayload(name="greeting", args={"text": "crap happens"}) ctx = PluginContext(global_context=GlobalContext(request_id="t-1")) res: PromptPrehookResult = await plugin.prompt_pre_fetch(payload, ctx) assert res.continue_processing assert res.modified_payload.args["text"] == "crud happens"Unit test violation behavior (native):
import pytest from mcpgateway.plugins.framework import ( HookType, PluginConfig, PluginContext, GlobalContext, PromptPrehookPayload, PluginViolation, ) from plugins.deny_filter.deny import DenyListPlugin @pytest.mark.asyncio async def test_denylist_blocks(): cfg = PluginConfig( name="deny", kind="plugins.deny_filter.deny.DenyListPlugin", hooks=[HookType.PROMPT_PRE_FETCH], priority=10, config={"words": ["blocked"]}, ) plugin = DenyListPlugin(cfg) payload = PromptPrehookPayload(name="any", args={"x": "this is blocked text"}) ctx = PluginContext(global_context=GlobalContext(request_id="t-2")) res = await plugin.prompt_pre_fetch(payload, ctx) assert res.continue_processing is False assert isinstance(res.violation, PluginViolation)Integration test the pipeline via
PluginManager:import pytest from mcpgateway.plugins.framework.manager import PluginManager from mcpgateway.plugins.framework import GlobalContext, PromptPrehookPayload @pytest.mark.asyncio async def test_manager_runs_plugins(tmp_path): # Create a minimal config.yaml scoped to test cfg = tmp_path / "plugins.yaml" cfg.write_text( """ plugins: - name: "PIIFilterPlugin" kind: "plugins.pii_filter.pii_filter.PIIFilterPlugin" hooks: ["prompt_pre_fetch"] mode: "permissive" priority: 1 config: detect_email: true default_mask_strategy: "partial" plugin_settings: plugin_timeout: 5 fail_on_plugin_error: false plugin_dirs: [] """, encoding="utf-8", ) mgr = PluginManager(str(cfg), timeout=5) await mgr.initialize() ctx = GlobalContext(request_id="req-1") payload = PromptPrehookPayload(name="p", args={"msg": "email me at dev@example.com"}) res, _ = await mgr.prompt_pre_fetch(payload, ctx) assert res.continue_processing assert res.modified_payload is None or "@example.com" in (res.modified_payload.args.get("msg", "")) await mgr.shutdown()Testing external plugins (unit):
- For server code, unit test the underlying policy/transform functions directly, and mock I/O (e.g., mock
requests.postin the OPA plugin). - Keep tests deterministic and fast; avoid network in unit tests.
- For server code, unit test the underlying policy/transform functions directly, and mock I/O (e.g., mock
Testing external plugins (integration with MCP client):
- In the external plugin project directory, start the MCP server:
make start(default Streamable HTTP athttp://localhost:8000/mcp). - From a test, connect using the MCP Python client and call the hook tool:
import pytest, json from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client from mcpgateway.plugins.framework.models import HookType @pytest.mark.asyncio async def test_mcp_server_tool_pre_invoke(): async with (await streamablehttp_client("http://localhost:8000/mcp")) as (http, write, _): async with ClientSession(http, write) as session: await session.initialize() # Minimal payload/context as JSON-serializable dicts payload = {"name": "some_tool", "args": {"x": "y"}} context = {"state": {}, "metadata": {}, "global_context": {"request_id": "it-1", "state": {}, "metadata": {}}} rsp = await session.call_tool(HookType.TOOL_PRE_INVOKE, {"plugin_name": "MyExternal", "payload": payload, "context": context}) txt = rsp.content[0].text data = json.loads(txt) assert "result" in data or "error" in data
- In the external plugin project directory, start the MCP server:
Gateway E2E smoke test with external plugin:
- Generate a token and export it (JWT helper):
export MCPGATEWAY_BEARER_TOKEN=$(python -m mcpgateway.utils.create_jwt_token --username admin@example.com --exp 60 --secret KEY) - Ensure
.envhasPLUGINS_ENABLED=trueandplugins/config.yamlincludes your external plugin pointing tohttp://localhost:8000/mcp. - Start gateway:
make serve. - Trigger a tool call (fires
tool_pre_invoke):curl -s -X POST -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"example-tool","params":{"x":"y"}}' \ http://localhost:4444/rpc
- Generate a token and export it (JWT helper):
Performance and timeouts:
- To test timeout handling, configure
plugin_settings.plugin_timeoutlow (e.g., 1–2s) and create a test plugin thatawait asyncio.sleep(timeout+ε)inside a hook, then assert the manager error behavior per mode/settings. - Use
pytest.mark.slowsparingly; default tests should be fast and deterministic.
- To test timeout handling, configure