Skip to content

Commit 8165825

Browse files
author
Teryl Taylor
committed
tests(plugins): added client hook tests for external plugins.
Signed-off-by: Teryl Taylor <[email protected]>
1 parent a43323c commit 8165825

File tree

6 files changed

+307
-3
lines changed

6 files changed

+307
-3
lines changed

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

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,23 @@
3232
PYTHON,
3333
PYTHON_SUFFIX,
3434
)
35-
from mcpgateway.plugins.framework.models import HookType, PluginConfig, PluginContext, PromptPosthookPayload, PromptPosthookResult, PromptPrehookPayload, PromptPrehookResult
35+
from mcpgateway.plugins.framework.models import (
36+
HookType,
37+
PluginConfig,
38+
PluginContext,
39+
PromptPosthookPayload,
40+
PromptPosthookResult,
41+
PromptPrehookPayload,
42+
PromptPrehookResult,
43+
ResourcePostFetchPayload,
44+
ResourcePostFetchResult,
45+
ResourcePreFetchPayload,
46+
ResourcePreFetchResult,
47+
ToolPostInvokePayload,
48+
ToolPostInvokeResult,
49+
ToolPreInvokePayload,
50+
ToolPreInvokeResult,
51+
)
3652
from mcpgateway.schemas import TransportType
3753

3854
logger = logging.getLogger(__name__)
@@ -162,6 +178,74 @@ async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: Plugi
162178
return PromptPosthookResult.model_validate(res)
163179
return PromptPosthookResult(continue_processing=True)
164180

181+
async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult:
182+
"""Plugin hook run before a tool is invoked.
183+
184+
Args:
185+
payload: The tool payload to be analyzed.
186+
context: contextual information about the hook call. Including why it was called.
187+
188+
Returns:
189+
The tool prehook with name and arguments as modified or blocked by the plugin.
190+
"""
191+
192+
result = await self._session.call_tool(HookType.TOOL_PRE_INVOKE, {PLUGIN_NAME: self.name, PAYLOAD: payload, CONTEXT: context})
193+
for content in result.content:
194+
res = json.loads(content.text)
195+
return ToolPreInvokeResult.model_validate(res)
196+
return ToolPreInvokeResult(continue_processing=True)
197+
198+
async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult:
199+
"""Plugin hook run after a tool is invoked.
200+
201+
Args:
202+
payload: The tool payload to be analyzed.
203+
context: contextual information about the hook call. Including why it was called.
204+
205+
Returns:
206+
The tool posthook with name and arguments as modified or blocked by the plugin.
207+
"""
208+
209+
result = await self._session.call_tool(HookType.TOOL_POST_INVOKE, {PLUGIN_NAME: self.name, PAYLOAD: payload, CONTEXT: context})
210+
for content in result.content:
211+
res = json.loads(content.text)
212+
return ToolPostInvokeResult.model_validate(res)
213+
return ToolPostInvokeResult(continue_processing=True)
214+
215+
async def resource_pre_fetch(self, payload: ResourcePreFetchPayload, context: PluginContext) -> ResourcePreFetchResult:
216+
"""Plugin hook run before a resource is fetched.
217+
218+
Args:
219+
payload: The resource payload to be analyzed.
220+
context: contextual information about the hook call. Including why it was called.
221+
222+
Returns:
223+
The resource prehook with name and arguments as modified or blocked by the plugin.
224+
"""
225+
226+
result = await self._session.call_tool(HookType.RESOURCE_PRE_FETCH, {PLUGIN_NAME: self.name, PAYLOAD: payload, CONTEXT: context})
227+
for content in result.content:
228+
res = json.loads(content.text)
229+
return ResourcePreFetchResult.model_validate(res)
230+
return ResourcePreFetchResult(continue_processing=True)
231+
232+
async def resource_post_fetch(self, payload: ResourcePostFetchPayload, context: PluginContext) -> ResourcePostFetchResult:
233+
"""Plugin hook run after a resource is fetched.
234+
235+
Args:
236+
payload: The resource payload to be analyzed.
237+
context: contextual information about the hook call. Including why it was called.
238+
239+
Returns:
240+
The resource posthook with name and arguments as modified or blocked by the plugin.
241+
"""
242+
243+
result = await self._session.call_tool(HookType.RESOURCE_POST_FETCH, {PLUGIN_NAME: self.name, PAYLOAD: payload, CONTEXT: context})
244+
for content in result.content:
245+
res = json.loads(content.text)
246+
return ResourcePostFetchResult.model_validate(res)
247+
return ResourcePostFetchResult(continue_processing=True)
248+
165249
async def __get_plugin_config(self) -> PluginConfig | None:
166250
"""Retrieve plugin configuration for the current plugin on the remote MCP server.
167251
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
plugins:
2+
# Self-contained Search Replace Plugin
3+
- name: "PassThroughPlugin"
4+
kind: "tests.unit.mcpgateway.plugins.fixtures.plugins.passthrough.PassThroughPlugin"
5+
description: "A simple passthrough plugin."
6+
version: "0.1"
7+
author: "MCP Context Forge Team"
8+
hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke", "resource_pre_fetch", "resource_post_fetch"]
9+
tags: ["plugin", "passthrough"]
10+
mode: "enforce" # enforce | permissive | disabled
11+
priority: 150
12+
conditions:
13+
# Apply to specific tools/servers
14+
- prompts: []
15+
server_ids: [] # Apply to all servers
16+
tenant_ids: [] # Apply to all tenants
17+
18+
# Plugin directories to scan
19+
plugin_dirs:
20+
- "plugins/native" # Built-in plugins
21+
- "plugins/custom" # Custom organization plugins
22+
- "/etc/mcpgateway/plugins" # System-wide plugins
23+
24+
# Global plugin settings
25+
plugin_settings:
26+
parallel_execution_within_band: true
27+
plugin_timeout: 30
28+
fail_on_plugin_error: false
29+
enable_plugin_api: true
30+
plugin_health_check_interval: 60
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# plugins/config.yaml - Main plugin configuration file
2+
3+
plugins:
4+
- name: "PassThroughPlugin"
5+
kind: "external"
6+
mcp:
7+
proto: STDIO
8+
script: mcpgateway/plugins/framework/external/mcp/server/runtime.py
9+
10+
# Plugin directories to scan
11+
plugin_dirs:
12+
- "plugins/native" # Built-in plugins
13+
- "plugins/custom" # Custom organization plugins
14+
- "/etc/mcpgateway/plugins" # System-wide plugins
15+
16+
# Global plugin settings
17+
plugin_settings:
18+
parallel_execution_within_band: true
19+
plugin_timeout: 30
20+
fail_on_plugin_error: false
21+
enable_plugin_api: true
22+
plugin_health_check_interval: 60
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
4+
Copyright 2025
5+
SPDX-License-Identifier: Apache-2.0
6+
Authors: Teryl Taylor
7+
8+
"""
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""
2+
Passthrough plugin.
3+
4+
Copyright 2025
5+
SPDX-License-Identifier: Apache-2.0
6+
"""
7+
8+
9+
from mcpgateway.plugins.framework import (
10+
Plugin,
11+
PluginContext,
12+
PromptPosthookPayload,
13+
PromptPosthookResult,
14+
PromptPrehookPayload,
15+
PromptPrehookResult,
16+
ResourcePostFetchPayload,
17+
ResourcePostFetchResult,
18+
ResourcePreFetchPayload,
19+
ResourcePreFetchResult,
20+
ToolPostInvokePayload,
21+
ToolPostInvokeResult,
22+
ToolPreInvokePayload,
23+
ToolPreInvokeResult,
24+
)
25+
26+
class PassThroughPlugin(Plugin):
27+
"""A simple pass through plugin."""
28+
29+
async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult:
30+
"""The plugin hook run before a prompt is retrieved and rendered.
31+
32+
Args:
33+
payload: The prompt payload to be analyzed.
34+
context: contextual information about the hook call.
35+
36+
Returns:
37+
The result of the plugin's analysis, including whether the prompt can proceed.
38+
"""
39+
return PromptPrehookResult(continue_processing=True)
40+
41+
async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: PluginContext) -> PromptPosthookResult:
42+
"""Plugin hook run after a prompt is rendered.
43+
44+
Args:
45+
payload: The prompt payload to be analyzed.
46+
context: Contextual information about the hook call.
47+
48+
Returns:
49+
The result of the plugin's analysis, including whether the prompt can proceed.
50+
"""
51+
return PromptPosthookResult(continue_processing=True)
52+
53+
async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult:
54+
"""Plugin hook run before a tool is invoked.
55+
56+
Args:
57+
payload: The tool payload to be analyzed.
58+
context: Contextual information about the hook call.
59+
60+
Returns:
61+
The result of the plugin's analysis, including whether the tool can proceed.
62+
"""
63+
return ToolPreInvokeResult(continue_processing=True)
64+
65+
async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult:
66+
"""Plugin hook run after a tool is invoked.
67+
68+
Args:
69+
payload: The tool result payload to be analyzed.
70+
context: Contextual information about the hook call.
71+
72+
Returns:
73+
The result of the plugin's analysis, including whether the tool result should proceed.
74+
"""
75+
return ToolPostInvokeResult(continue_processing=True)
76+
77+
async def resource_post_fetch(self, payload: ResourcePostFetchPayload, context: PluginContext) -> ResourcePostFetchResult:
78+
"""Plugin hook run after a resource was fetched.
79+
80+
Args:
81+
payload: The resource result payload to be analyzed.
82+
context: Contextual information about the hook call.
83+
84+
Returns:
85+
The result of the plugin's analysis, including whether the resource result should proceed.
86+
"""
87+
return ResourcePostFetchResult(continue_processing=True)
88+
89+
async def resource_pre_fetch(self, payload: ResourcePreFetchPayload, context: PluginContext) -> ResourcePreFetchResult:
90+
"""Plugin hook run before a resource was fetched.
91+
92+
Args:
93+
payload: The resource result payload to be analyzed.
94+
context: Contextual information about the hook call.
95+
96+
Returns:
97+
The result of the plugin's analysis, including whether the resource result should proceed.
98+
"""
99+
return ResourcePreFetchResult(continue_processing=True)

tests/unit/mcpgateway/plugins/framework/external/mcp/test_client_stdio.py

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,21 @@
1313
from mcp import ClientSession, StdioServerParameters
1414
from mcp.client.stdio import stdio_client
1515

16-
from mcpgateway.models import Message, PromptResult, Role, TextContent
17-
from mcpgateway.plugins.framework import ConfigLoader, PluginConfig, PluginLoader, PluginContext, PromptPrehookPayload, PromptPosthookPayload
16+
from mcpgateway.models import Message, PromptResult, ResourceContent, Role, TextContent
17+
from mcpgateway.plugins.framework import (
18+
ConfigLoader,
19+
GlobalContext,
20+
PluginConfig,
21+
PluginLoader,
22+
PluginManager,
23+
PluginContext,
24+
PromptPrehookPayload,
25+
PromptPosthookPayload,
26+
ResourcePostFetchPayload,
27+
ResourcePreFetchPayload,
28+
ToolPostInvokePayload,
29+
ToolPreInvokePayload,
30+
)
1831
from plugins.regex_filter.search_replace import SearchReplaceConfig
1932

2033

@@ -143,3 +156,51 @@ async def test_client_get_plugin_configs():
143156
assert srconfig.words[0].search == "crap"
144157
assert srconfig.words[0].replace == "crud"
145158
assert len(all_configs) == 2
159+
160+
@pytest.mark.asyncio
161+
async def test_hooks():
162+
os.environ["PLUGINS_CONFIG_PATH"] = "tests/unit/mcpgateway/plugins/fixtures/configs/valid_single_plugin_passthrough.yaml"
163+
os.environ["PYTHONPATH"] = "."
164+
pm = PluginManager()
165+
if pm.initialized:
166+
await pm.shutdown()
167+
plugin_manager = PluginManager(config="tests/unit/mcpgateway/plugins/fixtures/configs/valid_stdio_external_plugin_passthrough.yaml")
168+
await plugin_manager.initialize()
169+
payload = PromptPrehookPayload(name="test_prompt", args={"arg0": "This is a crap argument"})
170+
global_context = GlobalContext(request_id="1")
171+
result, _ = await plugin_manager.prompt_pre_fetch(payload, global_context)
172+
# Assert expected behaviors
173+
assert result.continue_processing
174+
"""Test prompt post hook across all registered plugins."""
175+
# Customize payload for testing
176+
message = Message(content=TextContent(type="text", text="prompt"), role=Role.USER)
177+
prompt_result = PromptResult(messages=[message])
178+
payload = PromptPosthookPayload(name="test_prompt", result=prompt_result)
179+
result, _ = await plugin_manager.prompt_post_fetch(payload, global_context)
180+
# Assert expected behaviors
181+
assert result.continue_processing
182+
"""Test tool pre hook across all registered plugins."""
183+
# Customize payload for testing
184+
payload = ToolPreInvokePayload(name="test_prompt", args={"arg0": "This is an argument"})
185+
result, _ = await plugin_manager.tool_pre_invoke(payload, global_context)
186+
# Assert expected behaviors
187+
assert result.continue_processing
188+
"""Test tool post hook across all registered plugins."""
189+
# Customize payload for testing
190+
payload = ToolPostInvokePayload(name="test_tool", result={"output0": "output value"})
191+
result, _ = await plugin_manager.tool_post_invoke(payload, global_context)
192+
# Assert expected behaviors
193+
assert result.continue_processing
194+
195+
payload = ResourcePreFetchPayload(uri="file:///data.txt")
196+
result, _ = await plugin_manager.resource_pre_fetch(payload, global_context)
197+
# Assert expected behaviors
198+
assert result.continue_processing
199+
200+
content = ResourceContent(type="resource", uri="file:///data.txt",
201+
text="Hello World")
202+
payload = ResourcePostFetchPayload(uri="file:///data.txt", content=content)
203+
result, _ = await plugin_manager.resource_post_fetch(payload, global_context)
204+
# Assert expected behaviors
205+
assert result.continue_processing
206+
await plugin_manager.shutdown()

0 commit comments

Comments
 (0)