Skip to content

Commit dd7092e

Browse files
teryltTeryl Tayloraraujofcrivetimihai
authored
feat: initial revision of adding plugin support. (#642)
* feat: initial revision of adding plugin support. Signed-off-by: Teryl Taylor <[email protected]> * feat(plugins): added prompt posthook functionality with executor, fixed some linting issues, updated example plugin with posthook. Signed-off-by: Teryl Taylor <[email protected]> * feat(plugins): integrated plugins into prompt service, fixed linting and type issues. Signed-off-by: Teryl Taylor <[email protected]> * fix(plugins): renamed types.py to plugin_types.py due to conflict in pytest Signed-off-by: Teryl Taylor <[email protected]> * fix(plugins): fixed renamed plugin_types module in prompt_service.py Signed-off-by: Teryl Taylor <[email protected]> * feat: add example filter plugin Signed-off-by: Frederico Araujo <[email protected]> * feat: add plugin violation error object Signed-off-by: Frederico Araujo <[email protected]> * feat: added unit tests Signed-off-by: Teryl Taylor <[email protected]> * fix(license): add licensing to unit test files and run lint. Signed-off-by: Teryl Taylor <[email protected]> * fix(plugin): linting issues. Signed-off-by: Teryl Taylor <[email protected]> * fix(plugins): added yaml dependency for plugins to pyproject.toml Signed-off-by: Teryl Taylor <[email protected]> * test(plugin): added tests for filter plugins Signed-off-by: Teryl Taylor <[email protected]> * test(plugin): add missing config files for plugin tests Signed-off-by: Teryl Taylor <[email protected]> * Add PII filter plugin Signed-off-by: Mihai Criveti <[email protected]> * docs(plugins): updated plugins documentation. Signed-off-by: Teryl Taylor <[email protected]> * fix: include plugin md files in manifest Signed-off-by: Frederico Araujo <[email protected]> * docs: add deny list plugin readme Signed-off-by: Frederico Araujo <[email protected]> * Pre-commit cleanup Signed-off-by: Mihai Criveti <[email protected]> * Improved manager.py, add doctest and safety mechanisms to plugin framework (timeout, memory cleanup, validation) Signed-off-by: Mihai Criveti <[email protected]> * Add README and renamed plugins with filter prefix Signed-off-by: Mihai Criveti <[email protected]> --------- Signed-off-by: Teryl Taylor <[email protected]> Signed-off-by: Frederico Araujo <[email protected]> Signed-off-by: Mihai Criveti <[email protected]> Co-authored-by: Teryl Taylor <[email protected]> Co-authored-by: Frederico Araujo <[email protected]> Co-authored-by: Mihai Criveti <[email protected]>
1 parent 0587f24 commit dd7092e

38 files changed

+4434
-5
lines changed

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,3 +290,8 @@ DEBUG=false
290290
# Gateway tool name separator
291291
GATEWAY_TOOL_NAME_SEPARATOR=-
292292
VALID_SLUG_SEPARATOR_REGEXP= r"^(-{1,2}|[_.])$"
293+
294+
#####################################
295+
# Plugins Settings
296+
#####################################
297+
PLUGINS_ENABLED=false

MANIFEST.in

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ recursive-include alembic *.md
5656
recursive-include alembic *.py
5757
# recursive-include deployment *
5858
# recursive-include mcp-servers *
59+
recursive-include plugins *.py
60+
recursive-include plugins *.yaml
61+
recursive-include plugins *.md
5962

6063
# 5️⃣ (Optional) include MKDocs-based docs in the sdist
6164
# graft docs

docs/docs/using/plugins/index.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ PLUGIN_CONFIG_FILE=plugins/config.yaml
4646

4747
### 2. Plugin Configuration
4848

49+
The plugin configuration file is used to configure a set of plugins to run a
50+
set of hook points throughout the MCP Context Forge. An example configuration
51+
is below. It contains two main sections: `plugins` and `plugin_settings`.
52+
4953
Create or modify `plugins/config.yaml`:
5054

5155
```yaml
@@ -78,6 +82,35 @@ plugin_settings:
7882
plugin_health_check_interval: 60
7983
```
8084
85+
The `plugins` section lists the set of configured plugins that will be loaded
86+
by the Context Forge at startup. Each plugin contains a set of standard configurations,
87+
and then a `config` section designed for plugin specific configurations. The attributes
88+
are defined as follows:
89+
90+
| Attribute | Description | Example Value |
91+
|-----------|-------------|---------------|
92+
| **name** | A unique name for the plugin. | MyFirstPlugin |
93+
| **kind** | A fully qualified string representing the plugin python object. | plugins.native.content_filter.ContentFilterPlugin |
94+
| **description** | The description of the plugin configuration. | A plugin for replacing bad words. |
95+
| **version** | The version of the plugin configuration. | 0.1 |
96+
| **author** | The team that wrote the plugin. | MCP Context Forge |
97+
| **hooks** | A list of hooks for which the plugin will be executed. **Note**: currently supports two hooks: "prompt_pre_fetch", "prompt_post_fetch" | ["prompt_pre_fetch", "prompt_post_fetch"] |
98+
| **tags** | Descriptive keywords that make the configuration searchable. | ["security", "filter"] |
99+
| **mode** | Mode of operation of the plugin. - enforce (stops during a violation), permissive (audits a violation but doesn't stop), disabled (disabled) | permissive |
100+
| **priority** | The priority in which the plugin will run - 0 is higher priority | 100 |
101+
| **conditions** | A list of conditions under which a plugin is run. See section on conditions.| |
102+
| **config** | Plugin specific configuration. This is a dictionary and is passed to the plugin on initialization. | |
103+
104+
The `plugin_settings` are as follows:
105+
106+
| Attribute | Description | Example Value |
107+
|-----------|-------------|---------------|
108+
| **parallel_execution_within_band** | Plugins in the same band are run in parallel (currently not implemented). | true or false |
109+
| **plugin_timeout** | The time in seconds before stopping plugin execution (not implemented). | 30 |
110+
| **fail_on_plugin_error** | Cause the execution of the task to fail if the plugin errors. | true or false |
111+
| **plugin_health_check_interval** | Health check interval in seconds (not implemented). | 60 |
112+
113+
81114
### 3. Execution Modes
82115

83116
Each plugin can operate in one of three modes:
@@ -110,6 +143,18 @@ plugins:
110143

111144
Plugins with the same priority may execute in parallel if `parallel_execution_within_band` is enabled.
112145

146+
### 5. Conditions of Execution
147+
148+
Users may only want plugins to be invoked on specific servers, tools, and prompts. To address this, a set of conditionals can be applied to a plugin. The attributes in a conditional combine together in as a set of `and` operations, while each attribute list item is `ored` with other items in the list. The attributes are defined as follows:
149+
150+
| Attribute | Description
151+
|-----------|------------|
152+
| **server_ids** | The list of MCP servers on which the plugin will trigger |
153+
| **tools** | The list of tools on which the plugin will be applied. |
154+
| **prompts** | The list of prompts on which the plugin will be applied. |
155+
| **user_patterns** | The list of users on which the plugin will be applied. |
156+
| **content_types** | The list of content types on which the plugin will trigger. |
157+
113158
## Available Hooks
114159

115160
Currently implemented hooks:

mcpgateway/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,10 @@ def _parse_federation_peers(cls, v):
311311
use_stateful_sessions: bool = False # Set to False to use stateless sessions without event store
312312
json_response_enabled: bool = True # Enable JSON responses instead of SSE streams
313313

314+
# Core plugin settings
315+
plugins_enabled: bool = Field(default=False, description="Enable the plugin framework")
316+
plugin_config_file: str = Field(default="plugins/config.yaml", description="Path to main plugin configuration file")
317+
314318
# Development
315319
dev_mode: bool = False
316320
reload: bool = False

mcpgateway/main.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
ResourceContent,
7676
Root,
7777
)
78+
from mcpgateway.plugins import PluginManager, PluginViolationError
7879
from mcpgateway.schemas import (
7980
GatewayCreate,
8081
GatewayRead,
@@ -160,6 +161,8 @@
160161
else:
161162
loop.create_task(bootstrap_db())
162163

164+
# Initialize plugin manager as a singleton.
165+
plugin_manager: PluginManager | None = PluginManager(settings.plugin_config_file) if settings.plugins_enabled else None
163166

164167
# Initialize services
165168
tool_service = ToolService()
@@ -214,6 +217,9 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
214217
"""
215218
logger.info("Starting MCP Gateway services")
216219
try:
220+
if plugin_manager:
221+
await plugin_manager.initialize()
222+
logger.info(f"Plugin manager initialized with {plugin_manager.plugin_count} plugins")
217223
await tool_service.initialize()
218224
await resource_service.initialize()
219225
await prompt_service.initialize()
@@ -232,6 +238,13 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
232238
logger.error(f"Error during startup: {str(e)}")
233239
raise
234240
finally:
241+
# Shutdown plugin manager
242+
if plugin_manager:
243+
try:
244+
await plugin_manager.shutdown()
245+
logger.info("Plugin manager shutdown complete")
246+
except Exception as e:
247+
logger.error(f"Error shutting down plugin manager: {str(e)}")
235248
logger.info("Shutting down MCP Gateway services")
236249
# await stop_streamablehttp()
237250
for service in [resource_cache, sampling_handler, logging_service, completion_service, root_service, gateway_service, prompt_service, resource_service, tool_service, streamable_http_session]:
@@ -1703,9 +1716,11 @@ async def get_prompt(
17031716
PromptExecuteArgs(args=args)
17041717
return await prompt_service.get_prompt(db, name, args)
17051718
except Exception as ex:
1706-
logger.error(f"Error retrieving prompt {name}: {ex}")
1707-
if isinstance(ex, ValueError):
1719+
logger.error(f"Could not retrieve prompt {name}: {ex}")
1720+
if isinstance(ex, ValueError) or isinstance(ex, PromptError):
17081721
return JSONResponse(content={"message": "Prompt execution arguments contains HTML tags that may cause security issues"}, status_code=422)
1722+
if isinstance(ex, PluginViolationError):
1723+
return JSONResponse(content={"message": "Prompt execution arguments contains HTML tags that may cause security issues", "details": ex.message}, status_code=422)
17091724

17101725

17111726
@prompt_router.get("/{name}")

mcpgateway/plugins/__init__.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# -*- coding: utf-8 -*-
2+
"""Services Package.
3+
4+
Copyright 2025
5+
SPDX-License-Identifier: Apache-2.0
6+
Authors: Fred Araujo
7+
8+
Exposes core MCP Gateway plugin components:
9+
- Context
10+
- Manager
11+
- Payloads
12+
- Models
13+
"""
14+
15+
from mcpgateway.plugins.framework.manager import PluginManager
16+
from mcpgateway.plugins.framework.models import PluginViolation
17+
from mcpgateway.plugins.framework.plugin_types import GlobalContext, PluginViolationError, PromptPosthookPayload, PromptPrehookPayload
18+
19+
__all__ = [
20+
"GlobalContext",
21+
"PluginManager",
22+
"PluginViolation",
23+
"PluginViolationError",
24+
"PromptPosthookPayload",
25+
"PromptPrehookPayload",
26+
]

mcpgateway/plugins/framework/base.py

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
# -*- coding: utf-8 -*-
2+
"""Base plugin implementation.
3+
4+
Copyright 2025
5+
SPDX-License-Identifier: Apache-2.0
6+
Authors: Teryl Taylor
7+
8+
This module implements the base plugin object.
9+
It supports pre and post hooks AI safety, security and business processing
10+
for the following locations in the server:
11+
server_pre_register / server_post_register - for virtual server verification
12+
tool_pre_invoke / tool_post_invoke - for guardrails
13+
prompt_pre_fetch / prompt_post_fetch - for prompt filtering
14+
resource_pre_fetch / resource_post_fetch - for content filtering
15+
auth_pre_check / auth_post_check - for custom auth logic
16+
federation_pre_sync / federation_post_sync - for gateway federation
17+
"""
18+
19+
# Standard
20+
import uuid
21+
22+
# First-Party
23+
from mcpgateway.plugins.framework.models import HookType, PluginCondition, PluginConfig, PluginMode
24+
from mcpgateway.plugins.framework.plugin_types import (
25+
PluginContext,
26+
PromptPosthookPayload,
27+
PromptPosthookResult,
28+
PromptPrehookPayload,
29+
PromptPrehookResult,
30+
)
31+
32+
33+
class Plugin:
34+
"""Base plugin object for pre/post processing of inputs and outputs at various locations throughout the server."""
35+
36+
def __init__(self, config: PluginConfig) -> None:
37+
"""Initialize a plugin with a configuration and context.
38+
39+
Args:
40+
config: The plugin configuration
41+
"""
42+
self._config = config
43+
44+
@property
45+
def priority(self) -> int:
46+
"""Return the plugin's priority.
47+
48+
Returns:
49+
Plugin's priority.
50+
"""
51+
return self._config.priority
52+
53+
@property
54+
def mode(self) -> PluginMode:
55+
"""Return the plugin's mode.
56+
57+
Returns:
58+
Plugin's mode.
59+
"""
60+
return self._config.mode
61+
62+
@property
63+
def name(self) -> str:
64+
"""Return the plugin's name.
65+
66+
Returns:
67+
Plugin's name.
68+
"""
69+
return self._config.name
70+
71+
@property
72+
def hooks(self) -> list[HookType]:
73+
"""Return the plugin's currently configured hooks.
74+
75+
Returns:
76+
Plugin's configured hooks.
77+
"""
78+
return self._config.hooks
79+
80+
@property
81+
def tags(self) -> list[str]:
82+
"""Return the plugin's tags.
83+
84+
Returns:
85+
Plugin's tags.
86+
"""
87+
return self._config.tags
88+
89+
@property
90+
def conditions(self) -> list[PluginCondition] | None:
91+
"""Return the plugin's conditions for operation.
92+
93+
Returns:
94+
Plugin's conditions for executing.
95+
"""
96+
return self._config.conditions
97+
98+
async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult:
99+
"""Plugin hook run before a prompt is retrieved and rendered.
100+
101+
Args:
102+
payload: The prompt payload to be analyzed.
103+
context: contextual information about the hook call. Including why it was called.
104+
105+
Raises:
106+
NotImplementedError: needs to be implemented by sub class.
107+
"""
108+
raise NotImplementedError(
109+
f"""'prompt_pre_fetch' not implemented for plugin {self._config.name}
110+
of plugin type {type(self)}
111+
"""
112+
)
113+
114+
async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: PluginContext) -> PromptPosthookResult:
115+
"""Plugin hook run after a prompt is rendered.
116+
117+
Args:
118+
payload: The prompt payload to be analyzed.
119+
context: Contextual information about the hook call.
120+
121+
Raises:
122+
NotImplementedError: needs to be implemented by sub class.
123+
"""
124+
raise NotImplementedError(
125+
f"""'prompt_post_fetch' not implemented for plugin {self._config.name}
126+
of plugin type {type(self)}
127+
"""
128+
)
129+
130+
async def shutdown(self) -> None:
131+
"""Plugin cleanup code."""
132+
133+
134+
class PluginRef:
135+
"""Plugin reference which contains a uuid."""
136+
137+
def __init__(self, plugin: Plugin):
138+
"""Initialize a plugin reference.
139+
140+
Args:
141+
plugin: The plugin to reference.
142+
"""
143+
self._plugin = plugin
144+
self._uuid = uuid.uuid4()
145+
146+
@property
147+
def plugin(self) -> Plugin:
148+
"""Return the underlying plugin.
149+
150+
Returns:
151+
The underlying plugin.
152+
"""
153+
return self._plugin
154+
155+
@property
156+
def uuid(self) -> str:
157+
"""Return the plugin's UUID.
158+
159+
Returns:
160+
Plugin's UUID.
161+
"""
162+
return self._uuid.hex
163+
164+
@property
165+
def priority(self) -> int:
166+
"""Returns the plugin's priority.
167+
168+
Returns:
169+
Plugin's priority.
170+
"""
171+
return self._plugin.priority
172+
173+
@property
174+
def name(self) -> str:
175+
"""Return the plugin's name.
176+
177+
Returns:
178+
Plugin's name.
179+
"""
180+
return self._plugin.name
181+
182+
@property
183+
def hooks(self) -> list[HookType]:
184+
"""Returns the plugin's currently configured hooks.
185+
186+
Returns:
187+
Plugin's configured hooks.
188+
"""
189+
return self._plugin.hooks
190+
191+
@property
192+
def tags(self) -> list[str]:
193+
"""Return the plugin's tags.
194+
195+
Returns:
196+
Plugin's tags.
197+
"""
198+
return self._plugin.tags
199+
200+
@property
201+
def conditions(self) -> list[PluginCondition] | None:
202+
"""Return the plugin's conditions for operation.
203+
204+
Returns:
205+
Plugin's conditions for operation.
206+
"""
207+
return self._plugin.conditions
208+
209+
@property
210+
def mode(self) -> PluginMode:
211+
"""Return the plugin's mode.
212+
213+
Returns:
214+
Plugin's mode.
215+
"""
216+
return self.plugin.mode

0 commit comments

Comments
 (0)