McpPlugin Guide¶
McpPlugin intercepts mcp.client.session.ClientSession methods (call_tool, read_resource, get_prompt) and mcp.server.lowlevel.server.Server._handle_request at the class level. Each (direction, method, key) triple has its own independent FIFO queue, so you can mock multiple calls to different (or the same) MCP operations and they are consumed in registration order. The plugin supports both client-side and server-side interception.
Installation¶
This installs the mcp SDK.
Setup¶
In pytest, access McpPlugin through the tripwire.mcp proxy. It auto-creates the plugin for the current test on first use:
import pytest
import tripwire
@pytest.mark.asyncio
async def test_call_tool():
from mcp.client.session import ClientSession
tripwire.mcp.mock_call_tool(
"my_tool",
returns={"result": "ok"},
)
with tripwire:
session = object.__new__(ClientSession)
result = await session.call_tool("my_tool", {"key": "value"})
assert result == {"result": "ok"}
tripwire.mcp.assert_call_tool(
"my_tool",
arguments={"key": "value"},
direction="client",
)
For manual use outside pytest, construct McpPlugin explicitly:
from tripwire import StrictVerifier
from tripwire.plugins.mcp_plugin import McpPlugin
verifier = StrictVerifier()
mcp = McpPlugin(verifier)
Each verifier may have at most one McpPlugin. A second McpPlugin(verifier) raises ValueError.
The direction field¶
Every MCP interaction is tagged with a direction -- either "client" or "server":
"client": The code under test is an MCP client calling a remote server (viaClientSession.call_tool,ClientSession.read_resource, orClientSession.get_prompt)."server": The code under test is an MCP server receiving incoming requests (viaServer._handle_request).
Client-side and server-side mocks use separate registration methods, and the direction parameter on assertion helpers lets you verify which side the interaction came from.
Registering client mocks¶
Client mocks intercept ClientSession method calls. Three methods are available:
mock_call_tool(tool_name, *, returns, ...)¶
| Parameter | Type | Default | Description |
|---|---|---|---|
tool_name |
str |
required | Name of the MCP tool to mock |
returns |
Any |
required | Value to return when this mock is consumed |
raises |
BaseException \| None |
None |
Exception to raise instead of returning |
required |
bool |
True |
Whether an unused mock causes UnusedMocksError at teardown |
mock_read_resource(uri, *, returns, ...)¶
| Parameter | Type | Default | Description |
|---|---|---|---|
uri |
str |
required | Resource URI to mock |
returns |
Any |
required | Value to return when this mock is consumed |
raises |
BaseException \| None |
None |
Exception to raise instead of returning |
required |
bool |
True |
Whether an unused mock causes UnusedMocksError at teardown |
mock_get_prompt(prompt_name, *, returns, ...)¶
tripwire.mcp.mock_get_prompt("summarize", returns={"messages": [{"role": "user", "content": "..."}]})
| Parameter | Type | Default | Description |
|---|---|---|---|
prompt_name |
str |
required | Name of the prompt to mock |
returns |
Any |
required | Value to return when this mock is consumed |
raises |
BaseException \| None |
None |
Exception to raise instead of returning |
required |
bool |
True |
Whether an unused mock causes UnusedMocksError at teardown |
Registering server mocks¶
Server mocks intercept incoming requests handled by Server._handle_request. The API mirrors the client methods with a mock_server_ prefix:
mock_server_call_tool(tool_name, *, returns, ...)¶
mock_server_read_resource(uri, *, returns, ...)¶
mock_server_get_prompt(prompt_name, *, returns, ...)¶
tripwire.mcp.mock_server_get_prompt("greet", returns={"messages": [{"role": "assistant", "content": "Hello!"}]})
All three accept the same parameters as their client counterparts (returns, raises, required).
FIFO queues¶
Each (direction, method, key) triple has its own independent FIFO queue. Multiple mocks for the same tool/resource/prompt are consumed in registration order:
@pytest.mark.asyncio
async def test_multiple_tool_calls():
tripwire.mcp.mock_call_tool("search", returns={"results": ["a"]})
tripwire.mcp.mock_call_tool("search", returns={"results": ["b"]})
with tripwire:
from mcp.client.session import ClientSession
session = object.__new__(ClientSession)
r1 = await session.call_tool("search", {"query": "first"})
r2 = await session.call_tool("search", {"query": "second"})
assert r1 == {"results": ["a"]}
assert r2 == {"results": ["b"]}
tripwire.mcp.assert_call_tool("search", arguments={"query": "first"})
tripwire.mcp.assert_call_tool("search", arguments={"query": "second"})
Asserting interactions¶
Use the typed assertion helpers on tripwire.mcp. All recorded fields are required.
assert_call_tool(tool_name, *, arguments, direction)¶
tripwire.mcp.assert_call_tool(
"get_weather",
arguments={"city": "San Francisco"},
direction="client",
)
| Parameter | Type | Default | Description |
|---|---|---|---|
tool_name |
str |
required | Name of the MCP tool |
arguments |
dict[str, Any] \| None |
None |
Arguments passed to the tool (defaults to {} if None) |
direction |
str |
"client" |
"client" or "server" |
assert_read_resource(uri, *, direction)¶
| Parameter | Type | Default | Description |
|---|---|---|---|
uri |
str |
required | Resource URI |
direction |
str |
"client" |
"client" or "server" |
assert_get_prompt(prompt_name, *, arguments, direction)¶
| Parameter | Type | Default | Description |
|---|---|---|---|
prompt_name |
str |
required | Name of the prompt |
arguments |
dict[str, Any] \| None |
None |
Arguments passed to the prompt (defaults to {} if None) |
direction |
str |
"client" |
"client" or "server" |
Simulating errors¶
Use the raises parameter to simulate MCP errors:
@pytest.mark.asyncio
async def test_tool_error():
tripwire.mcp.mock_call_tool(
"flaky_tool",
returns=None,
raises=RuntimeError("MCP server unavailable"),
)
with tripwire:
from mcp.client.session import ClientSession
session = object.__new__(ClientSession)
with pytest.raises(RuntimeError, match="MCP server unavailable"):
await session.call_tool("flaky_tool", {"input": "data"})
tripwire.mcp.assert_call_tool(
"flaky_tool",
arguments={"input": "data"},
direction="client",
)
Full example¶
Production code (examples/mcp_tool/app.py):
"""Call an MCP tool via ClientSession and return the result."""
from mcp.client.session import ClientSession
async def fetch_weather(session: ClientSession, city: str) -> dict:
"""Call the 'get_weather' MCP tool and return its result."""
result = await session.call_tool("get_weather", {"city": city})
return result
Test (examples/mcp_tool/test_app.py):
"""Test MCP tool call using tripwire mcp_mock."""
import pytest
import tripwire
from .app import fetch_weather
@pytest.mark.asyncio
async def test_fetch_weather():
from mcp.client.session import ClientSession
tripwire.mcp.mock_call_tool(
"get_weather",
returns={"content": [{"type": "text", "text": "Sunny, 72F"}]},
)
with tripwire:
session = object.__new__(ClientSession)
result = await fetch_weather(session, "San Francisco")
assert result == {"content": [{"type": "text", "text": "Sunny, 72F"}]}
tripwire.mcp.assert_call_tool(
"get_weather",
arguments={"city": "San Francisco"},
direction="client",
)
Optional mocks¶
Mark a mock as optional with required=False:
An optional mock that is never triggered does not cause UnusedMocksError at teardown.
UnmockedInteractionError¶
When code makes an MCP call that has no remaining mocks in its queue, tripwire raises UnmockedInteractionError: