fastmcp/tests/server/middleware/test_middleware_nested.py
Jeremiah Lowin 3a9717e6be
Publish docs for v3.2.0 (#3713)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jeremiah Lowin <jlowin@users.noreply.github.com>
Co-authored-by: Marvin Context Protocol <41898282+Marvin Context Protocol@users.noreply.github.com>
Co-authored-by: voidborne-d <voidborne-d@users.noreply.github.com>
Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: d 🔹 <258577966+voidborne-d@users.noreply.github.com>
Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Co-authored-by: nightcityblade <nightcityblade@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Bill Easton <strawgate@users.noreply.github.com>
Co-authored-by: Sumanshu Nankana <sumanshunankana@gmail.com>
Co-authored-by: Eric Robinson <ericrobinson@indeed.com>
Co-authored-by: Martim Santos <martimfasantos@gmail.com>
Co-authored-by: d 🔹 <liusway405@gmail.com>
Co-authored-by: Matthieu B <66959271+mtthidoteu@users.noreply.github.com>
Co-authored-by: Sascha Buehrle <47737812+saschabuehrle@users.noreply.github.com>
Co-authored-by: Hakancan <142545736+hkc5@users.noreply.github.com>
Co-authored-by: nightcityblade <jackchen@haloailabs.com>
Co-authored-by: Matt Hallowell <17804673+mhallo@users.noreply.github.com>
Co-authored-by: nate nowack <thrast36@gmail.com>
Co-authored-by: Bill Easton <williamseaston@gmail.com>
Co-authored-by: Marcus Shu <46469249+shulkx@users.noreply.github.com>
Co-authored-by: Rushabh Doshi <radoshi@gmail.com>
Co-authored-by: AIKAWA Shigechika <shige@aikawa.jp>
Co-authored-by: Jeremy Simon <simonjer805@gmail.com>
Co-authored-by: Miguel Miranda Dias <7780875+pandego@users.noreply.github.com>
Co-authored-by: Anthony James Padavano <padavano.anthony@gmail.com>
Co-authored-by: Mostafa Kamal <hiremostafa@gmail.com>
Fix auto-close MRE script posting comment without closing (#3386)
Fix WorkOS token scope verification bypass 🤖 Generated with Codex (#3407)
Fix initialize McpError fallthrough 🤖 Generated with Codex (#3413)
Fix transform arg collisions with passthrough params (#3431)
Fix get_* returning None when latest version is disabled (#3439)
Fix get_* returning None when latest version is disabled (#3421)
Fix server lifespan overlap teardown (#3415)
Fix $ref output schema object detection regression (#3420)
resolved annotations (#3429)
Fix async partial callables rejected by iscoroutinefunction (#3438)
Fix async partial callables rejected by iscoroutinefunction (#3423)
fix: add version to components (#3458)
fix: use intent-based flag for OIDC scope patch in load_access_token (#3465)
Fixes #3461
fix: normalize Google scope shorthands and surface valid_scopes (#3477)
fix: resolve ty 0.0.23 type-checking errors and bump pin (#3481)
fix: shield lifespan teardown from cancellation (#3480)
fix: forward custom_route endpoints from mounted servers (#3462)
fix updates _get_additional_http_routes() to traverse providers,
Fixes #3457
fix: remove hardcoded version from CLI help text (#3456)
fix: monty 0.0.8 compatibility, drop external_functions from constructor (#3468)
fix: task test teardown hanging 5s per test (#3499)
Closes #3498
fix: validate workspace path is a directory before cursor install (#3440)
Fixes #3426
fix: handle re.error from malformed URI templates in build_regex (#3501)
fix: reject empty/OIDC-only required_scopes in AzureProvider (#3503)
fix: restrict $ref resolution to local refs only (SSRF/LFI) (#3502)
fix warnings and timeouts (#3504)
close upgrade check issue when build passes (#3505)
Closes #3484
fix: URL-encode path params to prevent SSRF/path traversal (GHSA-vv7q-7jx5-f767) (#3507)
fix: prevent path traversal in skill download (#3493)
fix: prefer IdP-granted scopes over client-requested scopes in OAuthProxy (#3492)
fix: remove unrelated transform and http.py changes from PR scope
fix: remove forced follow_redirects from httpx_client_factory calls (#3496)
fix: stop passing follow_redirects to httpx_client_factory
fix: restore follow_redirects=True for custom httpx client factories
Closes #3509
fix: CSRF double-submit cookie check in consent flow (#3519)
fix: validate server names in install commands (#3522)
fix: use raw strings for regex in pytest.raises match (#3523)
fix: reject refresh tokens used as Bearer access tokens (#3524)
fix: route ResourcesAsTools/PromptsAsTools through server middleware (#3495)
fix: resolve Pyright "Module is not callable" on @tool, @resource, @prompt decorators (#3540)
fix: filter warnings by message in KEY_PREFIX test (#3549)
fix: suppress output schema for ToolResult subclass annotations (#3548)
fix: increase sleep duration in proxy cache tests (#3567)
fix: store absolute token expiry to prevent stale expires_in on reload (#3572)
fix: preserve tool properties named 'title' during schema compression (#3582)
Fix loopback redirect URI port matching per RFC 8252 §7.3 (#3589)
Fix app tool routing: visibility check and middleware propagation (#3591)
Fix query parameter serialization to respect OpenAPI explode/style settings (#3595)
Fix dev apps form: union types, textarea support, JSON parsing (#3597)
fix(google): replace deprecated /oauth2/v1/tokeninfo with /oauth2/v3/userinfo (#3603)
fix: resolve EntraOBOToken dependency injection through MultiAuth (#3609)
fix(docs): correct misleading stateless_http header (#3622)
fix: filesystem provider import machinery (#3626)
Closes #3625 (issues 2, 3, 6)
fix: recover StdioTransport after subprocess exits (#3630)
fix(server): preserve mounted tool task metadata (#3632)
fix: scope deprecation warning filter to FastMCPDeprecationWarning (#3649)
fix imports, add PrefabAppConfig (#3650)
fix: resolve CurrentFastMCP/ctx.fastmcp to child server in mounted background tasks (#3651)
Fix blocking docs issues: chart imports, Select API, Rx consistency (#3652)
closed by default (#3657)
Fix prompt caching middleware missing wrap/unwrap round-trip (#3666)
fix: serialize object query params per OpenAPI style/explode rules (#3662)
Fixes #2857
fix: HTTP request headers not accessible in background task workers (#3631)
fix: restore HTTP headers in worker execution path for background tasks (#3681)
fix: strip discriminator after dereferencing schemas (#3682)
fix: remove stale ty:ignore directives for ty 0.0.26 (#3684)
Fix docs gaps in app provider pages (#3690)
fix: dev apps log panel UX improvements (#3698)
fix dev server empty string args (#3700)
2026-03-30 16:48:30 -04:00

673 lines
26 KiB
Python

from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
import mcp.types
import pytest
from fastmcp import Client, FastMCP
from fastmcp.exceptions import ToolError
from fastmcp.server.context import Context
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
from fastmcp.tools.base import ToolResult
@dataclass
class Recording:
# the hook is the name of the hook that was called, e.g. "on_list_tools"
hook: str
context: MiddlewareContext
result: mcp.types.ServerResult | None
class RecordingMiddleware(Middleware):
"""A middleware that automatically records all method calls."""
def __init__(self, name: str | None = None):
super().__init__()
self.calls: list[Recording] = []
self.name = name
def __getattribute__(self, name: str) -> Callable:
"""Dynamically create recording methods for any on_* method."""
if name.startswith("on_"):
async def record_and_call(
context: MiddlewareContext, call_next: Callable
) -> Any:
result = await call_next(context)
self.calls.append(Recording(hook=name, context=context, result=result))
return result
return record_and_call
return super().__getattribute__(name)
def get_calls(
self, method: str | None = None, hook: str | None = None
) -> list[Recording]:
"""
Get all recorded calls for a specific method or hook.
Args:
method: The method to filter by (e.g. "tools/list")
hook: The hook to filter by (e.g. "on_list_tools")
Returns:
A list of recorded calls.
"""
calls = []
for recording in self.calls:
if method and hook:
if recording.context.method == method and recording.hook == hook:
calls.append(recording)
elif method:
if recording.context.method == method:
calls.append(recording)
elif hook:
if recording.hook == hook:
calls.append(recording)
else:
calls.append(recording)
return calls
def assert_called(
self,
hook: str | None = None,
method: str | None = None,
times: int | None = None,
at_least: int | None = None,
) -> bool:
"""Assert that a hook was called a specific number of times."""
if times is not None and at_least is not None:
raise ValueError("Cannot specify both times and at_least")
elif times is None and at_least is None:
times = 1
calls = self.get_calls(hook=hook, method=method)
actual_times = len(calls)
identifier = dict(hook=hook, method=method)
if times is not None:
assert actual_times == times, (
f"Expected {times} calls for {identifier}, "
f"but was called {actual_times} times"
)
elif at_least is not None:
assert actual_times >= at_least, (
f"Expected at least {at_least} calls for {identifier}, "
f"but was called {actual_times} times"
)
return True
def assert_not_called(self, hook: str | None = None, method: str | None = None):
"""Assert that a hook was not called."""
calls = self.get_calls(hook=hook, method=method)
assert len(calls) == 0, f"Expected {hook!r} to not be called"
return True
def reset(self):
"""Clear all recorded calls."""
self.calls.clear()
@pytest.fixture
def recording_middleware():
"""Fixture that provides a recording middleware instance."""
middleware = RecordingMiddleware(name="recording_middleware")
yield middleware
@pytest.fixture
def mcp_server(recording_middleware):
mcp = FastMCP()
@mcp.tool(tags={"add-tool"})
def add(a: int, b: int) -> int:
return a + b
@mcp.resource("resource://test")
def test_resource() -> str:
return "test resource"
@mcp.resource("resource://test-template/{x}")
def test_resource_with_path(x: int) -> str:
return f"test resource with {x}"
@mcp.prompt
def test_prompt(x: str) -> str:
return f"test prompt with {x}"
@mcp.tool
async def progress_tool(context: Context) -> None:
await context.report_progress(progress=1, total=10, message="test")
@mcp.tool
async def log_tool(context: Context) -> None:
await context.info(message="test log")
@mcp.tool
async def sample_tool(context: Context) -> None:
await context.sample("hello")
mcp.add_middleware(recording_middleware)
# Register progress handler
@mcp._mcp_server.progress_notification()
async def handle_progress(
progress_token: str | int,
progress: float,
total: float | None,
message: str | None,
):
print("HI")
return mcp
class TestNestedMiddlewareHooks:
@pytest.fixture
@staticmethod
def nested_middleware():
return RecordingMiddleware(name="nested_middleware")
@pytest.fixture
def nested_mcp_server(self, nested_middleware: RecordingMiddleware):
mcp = FastMCP(name="Nested MCP")
@mcp.tool
def add(a: int, b: int) -> int:
return a + b
@mcp.resource("resource://test")
def test_resource() -> str:
return "test resource"
@mcp.resource("resource://test-template/{x}")
def test_resource_with_path(x: int) -> str:
return f"test resource with {x}"
@mcp.prompt
def test_prompt(x: str) -> str:
return f"test prompt with {x}"
@mcp.tool
async def progress_tool(context: Context) -> None:
await context.report_progress(progress=1, total=10, message="test")
@mcp.tool
async def log_tool(context: Context) -> None:
await context.info(message="test log")
@mcp.tool
async def sample_tool(context: Context) -> None:
await context.sample("hello")
mcp.add_middleware(nested_middleware)
return mcp
async def test_call_tool_on_parent_server(
self,
mcp_server: FastMCP,
nested_mcp_server: FastMCP,
recording_middleware: RecordingMiddleware,
nested_middleware: RecordingMiddleware,
):
mcp_server.mount(nested_mcp_server, namespace="nested")
async with Client(mcp_server) as client:
await client.call_tool("add", {"a": 1, "b": 2})
assert recording_middleware.assert_called(at_least=3)
assert recording_middleware.assert_called(method="tools/call", at_least=3)
assert recording_middleware.assert_called(hook="on_message", at_least=1)
assert recording_middleware.assert_called(hook="on_request", at_least=1)
assert recording_middleware.assert_called(hook="on_call_tool", at_least=1)
assert nested_middleware.assert_called(method="tools/call", times=0)
async def test_call_tool_on_nested_server(
self,
mcp_server: FastMCP,
nested_mcp_server: FastMCP,
recording_middleware: RecordingMiddleware,
nested_middleware: RecordingMiddleware,
):
mcp_server.mount(nested_mcp_server, namespace="nested")
async with Client(mcp_server) as client:
await client.call_tool("nested_add", {"a": 1, "b": 2})
assert recording_middleware.assert_called(at_least=3)
assert recording_middleware.assert_called(method="tools/call", at_least=3)
assert recording_middleware.assert_called(hook="on_message", at_least=1)
assert recording_middleware.assert_called(hook="on_request", at_least=1)
assert recording_middleware.assert_called(hook="on_call_tool", at_least=1)
assert nested_middleware.assert_called(at_least=3)
assert nested_middleware.assert_called(method="tools/call", at_least=3)
assert nested_middleware.assert_called(hook="on_message", at_least=1)
assert nested_middleware.assert_called(hook="on_request", at_least=1)
assert nested_middleware.assert_called(hook="on_call_tool", at_least=1)
async def test_read_resource_on_parent_server(
self,
mcp_server: FastMCP,
nested_mcp_server: FastMCP,
recording_middleware: RecordingMiddleware,
nested_middleware: RecordingMiddleware,
):
mcp_server.mount(nested_mcp_server, namespace="nested")
async with Client(mcp_server) as client:
await client.read_resource("resource://test")
assert recording_middleware.assert_called(at_least=3)
assert recording_middleware.assert_called(method="resources/read", at_least=3)
assert recording_middleware.assert_called(hook="on_message", at_least=1)
assert recording_middleware.assert_called(hook="on_request", at_least=1)
assert recording_middleware.assert_called(hook="on_read_resource", at_least=1)
assert nested_middleware.assert_called(times=0)
async def test_read_resource_on_nested_server(
self,
mcp_server: FastMCP,
nested_mcp_server: FastMCP,
recording_middleware: RecordingMiddleware,
nested_middleware: RecordingMiddleware,
):
mcp_server.mount(nested_mcp_server, namespace="nested")
async with Client(mcp_server) as client:
await client.read_resource("resource://nested/test")
assert recording_middleware.assert_called(at_least=3)
assert recording_middleware.assert_called(method="resources/read", at_least=3)
assert recording_middleware.assert_called(hook="on_message", at_least=1)
assert recording_middleware.assert_called(hook="on_request", at_least=1)
assert recording_middleware.assert_called(hook="on_read_resource", at_least=1)
assert nested_middleware.assert_called(at_least=3)
assert nested_middleware.assert_called(method="resources/read", at_least=3)
assert nested_middleware.assert_called(hook="on_message", at_least=1)
assert nested_middleware.assert_called(hook="on_request", at_least=1)
assert nested_middleware.assert_called(hook="on_read_resource", at_least=1)
async def test_read_resource_template_on_parent_server(
self,
mcp_server: FastMCP,
nested_mcp_server: FastMCP,
recording_middleware: RecordingMiddleware,
nested_middleware: RecordingMiddleware,
):
mcp_server.mount(nested_mcp_server, namespace="nested")
async with Client(mcp_server) as client:
await client.read_resource("resource://test-template/1")
assert recording_middleware.assert_called(at_least=3)
assert recording_middleware.assert_called(method="resources/read", at_least=3)
assert recording_middleware.assert_called(hook="on_message", at_least=1)
assert recording_middleware.assert_called(hook="on_request", at_least=1)
assert recording_middleware.assert_called(hook="on_read_resource", at_least=1)
assert nested_middleware.assert_called(times=0)
async def test_read_resource_template_on_nested_server(
self,
mcp_server: FastMCP,
nested_mcp_server: FastMCP,
recording_middleware: RecordingMiddleware,
nested_middleware: RecordingMiddleware,
):
mcp_server.mount(nested_mcp_server, namespace="nested")
async with Client(mcp_server) as client:
await client.read_resource("resource://nested/test-template/1")
assert recording_middleware.assert_called(at_least=3)
assert recording_middleware.assert_called(method="resources/read", at_least=3)
assert recording_middleware.assert_called(hook="on_message", at_least=1)
assert recording_middleware.assert_called(hook="on_request", at_least=1)
assert recording_middleware.assert_called(hook="on_read_resource", at_least=1)
assert nested_middleware.assert_called(at_least=3)
assert nested_middleware.assert_called(method="resources/read", at_least=3)
assert nested_middleware.assert_called(hook="on_message", at_least=1)
assert nested_middleware.assert_called(hook="on_request", at_least=1)
assert nested_middleware.assert_called(hook="on_read_resource", at_least=1)
async def test_get_prompt_on_parent_server(
self,
mcp_server: FastMCP,
nested_mcp_server: FastMCP,
recording_middleware: RecordingMiddleware,
nested_middleware: RecordingMiddleware,
):
mcp_server.mount(nested_mcp_server, namespace="nested")
async with Client(mcp_server) as client:
await client.get_prompt("test_prompt", {"x": "test"})
assert recording_middleware.assert_called(at_least=3)
assert recording_middleware.assert_called(method="prompts/get", at_least=3)
assert recording_middleware.assert_called(hook="on_message", at_least=1)
assert recording_middleware.assert_called(hook="on_request", at_least=1)
assert recording_middleware.assert_called(hook="on_get_prompt", at_least=1)
assert nested_middleware.assert_called(times=0)
async def test_get_prompt_on_nested_server(
self,
mcp_server: FastMCP,
nested_mcp_server: FastMCP,
recording_middleware: RecordingMiddleware,
nested_middleware: RecordingMiddleware,
):
mcp_server.mount(nested_mcp_server, namespace="nested")
async with Client(mcp_server) as client:
await client.get_prompt("nested_test_prompt", {"x": "test"})
assert recording_middleware.assert_called(at_least=3)
assert recording_middleware.assert_called(method="prompts/get", at_least=3)
assert recording_middleware.assert_called(hook="on_message", at_least=1)
assert recording_middleware.assert_called(hook="on_request", at_least=1)
assert recording_middleware.assert_called(hook="on_get_prompt", at_least=1)
assert nested_middleware.assert_called(at_least=3)
assert nested_middleware.assert_called(method="prompts/get", at_least=3)
assert nested_middleware.assert_called(hook="on_message", at_least=1)
assert nested_middleware.assert_called(hook="on_request", at_least=1)
assert nested_middleware.assert_called(hook="on_get_prompt", at_least=1)
async def test_list_tools_on_nested_server(
self,
mcp_server: FastMCP,
nested_mcp_server: FastMCP,
recording_middleware: RecordingMiddleware,
nested_middleware: RecordingMiddleware,
):
mcp_server.mount(nested_mcp_server, namespace="nested")
async with Client(mcp_server) as client:
await client.list_tools()
assert recording_middleware.assert_called(at_least=3)
assert recording_middleware.assert_called(method="tools/list", at_least=3)
assert recording_middleware.assert_called(hook="on_message", at_least=1)
assert recording_middleware.assert_called(hook="on_request", at_least=1)
assert recording_middleware.assert_called(hook="on_list_tools", at_least=1)
assert nested_middleware.assert_called(at_least=3)
assert nested_middleware.assert_called(method="tools/list", at_least=3)
assert nested_middleware.assert_called(hook="on_message", at_least=1)
assert nested_middleware.assert_called(hook="on_request", at_least=1)
assert nested_middleware.assert_called(hook="on_list_tools", at_least=1)
async def test_list_resources_on_nested_server(
self,
mcp_server: FastMCP,
nested_mcp_server: FastMCP,
recording_middleware: RecordingMiddleware,
nested_middleware: RecordingMiddleware,
):
mcp_server.mount(nested_mcp_server, namespace="nested")
async with Client(mcp_server) as client:
await client.list_resources()
assert recording_middleware.assert_called(at_least=3)
assert recording_middleware.assert_called(method="resources/list", at_least=3)
assert recording_middleware.assert_called(hook="on_message", at_least=1)
assert recording_middleware.assert_called(hook="on_request", at_least=1)
assert recording_middleware.assert_called(hook="on_list_resources", at_least=1)
assert nested_middleware.assert_called(at_least=3)
assert nested_middleware.assert_called(method="resources/list", at_least=3)
assert nested_middleware.assert_called(hook="on_message", at_least=1)
assert nested_middleware.assert_called(hook="on_request", at_least=1)
assert nested_middleware.assert_called(hook="on_list_resources", at_least=1)
async def test_list_resource_templates_on_nested_server(
self,
mcp_server: FastMCP,
nested_mcp_server: FastMCP,
recording_middleware: RecordingMiddleware,
nested_middleware: RecordingMiddleware,
):
mcp_server.mount(nested_mcp_server, namespace="nested")
async with Client(mcp_server) as client:
await client.list_resource_templates()
assert recording_middleware.assert_called(at_least=3)
assert recording_middleware.assert_called(
method="resources/templates/list", at_least=3
)
assert recording_middleware.assert_called(hook="on_message", at_least=1)
assert recording_middleware.assert_called(hook="on_request", at_least=1)
assert recording_middleware.assert_called(
hook="on_list_resource_templates", at_least=1
)
assert nested_middleware.assert_called(at_least=3)
assert nested_middleware.assert_called(
method="resources/templates/list", at_least=3
)
assert nested_middleware.assert_called(hook="on_message", at_least=1)
assert nested_middleware.assert_called(hook="on_request", at_least=1)
assert nested_middleware.assert_called(
hook="on_list_resource_templates", at_least=1
)
class TestProxyServer:
async def test_call_tool(
self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
):
# proxy server will have its tools listed as well as called in order to
# apply transforms and filters prior to the call.
proxy_server = FastMCP.as_proxy(mcp_server, name="Proxy Server")
async with Client(proxy_server) as client:
await client.call_tool("add", {"a": 1, "b": 2})
assert recording_middleware.assert_called(at_least=6)
assert recording_middleware.assert_called(method="tools/call", at_least=3)
assert recording_middleware.assert_called(method="tools/list", at_least=3)
assert recording_middleware.assert_called(hook="on_message", at_least=2)
assert recording_middleware.assert_called(hook="on_request", at_least=2)
assert recording_middleware.assert_called(hook="on_call_tool", at_least=1)
assert recording_middleware.assert_called(hook="on_list_tools", at_least=1)
async def test_proxied_tags_are_visible_to_middleware(
self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
):
"""Tests that tags on remote FastMCP servers are visible to middleware
via proxy. See https://github.com/PrefectHQ/fastmcp/issues/1300"""
proxy_server = FastMCP.as_proxy(mcp_server, name="Proxy Server")
TAGS = []
class TagMiddleware(Middleware):
async def on_list_tools(self, context: MiddlewareContext, call_next):
nonlocal TAGS
result = await call_next(context)
for tool in result:
TAGS.append(tool.tags)
return result
proxy_server.add_middleware(TagMiddleware())
async with Client(proxy_server) as client:
await client.list_tools()
assert TAGS == [{"add-tool"}, set(), set(), set()]
class TestToolCallDenial:
"""Test denying tool calls in middleware using ToolError."""
async def test_deny_tool_call_with_tool_error(self):
"""Test that middleware can deny tool calls by raising ToolError."""
class AuthMiddleware(Middleware):
async def on_call_tool(
self,
context: MiddlewareContext[mcp.types.CallToolRequestParams],
call_next: CallNext[mcp.types.CallToolRequestParams, ToolResult],
) -> ToolResult:
tool_name = context.message.name
if tool_name.lower() == "restricted_tool":
raise ToolError("Access denied: tool is disabled")
return await call_next(context)
server = FastMCP("TestServer")
@server.tool
def allowed_tool(x: int) -> int:
"""This tool is allowed."""
return x * 2
@server.tool
def restricted_tool(x: int) -> int:
"""This tool should be denied by middleware."""
return x * 3
server.add_middleware(AuthMiddleware())
async with Client(server) as client:
# Allowed tool should work normally
result = await client.call_tool("allowed_tool", {"x": 5})
assert result.structured_content is not None
assert result.structured_content["result"] == 10
# Restricted tool should raise ToolError
with pytest.raises(ToolError) as exc_info:
await client.call_tool("restricted_tool", {"x": 5})
# Verify the error message is preserved
assert "Access denied: tool is disabled" in str(exc_info.value)
async def test_middleware_can_selectively_deny_tools(self):
"""Test that middleware can deny specific tools while allowing others."""
denied_tools = set()
class SelectiveAuthMiddleware(Middleware):
async def on_call_tool(
self,
context: MiddlewareContext[mcp.types.CallToolRequestParams],
call_next: CallNext[mcp.types.CallToolRequestParams, ToolResult],
) -> ToolResult:
tool_name = context.message.name
# Deny tools that start with "admin_"
if tool_name.startswith("admin_"):
denied_tools.add(tool_name)
raise ToolError(
f"Access denied: {tool_name} requires admin privileges"
)
return await call_next(context)
server = FastMCP("TestServer")
@server.tool
def public_tool(x: int) -> int:
"""Public tool available to all."""
return x + 1
@server.tool
def admin_delete(item_id: str) -> str:
"""Admin tool that should be denied."""
return f"Deleted {item_id}"
@server.tool
def admin_config(setting: str, value: str) -> str:
"""Another admin tool that should be denied."""
return f"Set {setting} to {value}"
server.add_middleware(SelectiveAuthMiddleware())
async with Client(server) as client:
# Public tool should work
result = await client.call_tool("public_tool", {"x": 10})
assert result.structured_content is not None
assert result.structured_content["result"] == 11
# Admin tools should be denied
with pytest.raises(ToolError) as exc_info:
await client.call_tool("admin_delete", {"item_id": "test123"})
assert "requires admin privileges" in str(exc_info.value)
with pytest.raises(ToolError) as exc_info:
await client.call_tool(
"admin_config", {"setting": "debug", "value": "true"}
)
assert "requires admin privileges" in str(exc_info.value)
# Verify both admin tools were denied
assert denied_tools == {"admin_delete", "admin_config"}
class TestMiddlewareRequestState:
"""Non-serializable state set in middleware must be visible to tools/resources.
Regression test for https://github.com/PrefectHQ/fastmcp/issues/3228.
"""
async def test_non_serializable_state_from_middleware_visible_in_tool(self):
server = FastMCP("test")
sentinel = object()
class StateMiddleware(Middleware):
async def on_call_tool(
self, context: MiddlewareContext, call_next: CallNext
) -> Any:
assert context.fastmcp_context is not None
await context.fastmcp_context.set_state(
"obj", sentinel, serializable=False
)
return await call_next(context)
server.add_middleware(StateMiddleware())
@server.tool()
async def read_it(ctx: Context) -> str:
val = await ctx.get_state("obj")
return "found" if val is sentinel else "missing"
async with Client(server) as client:
result = await client.call_tool("read_it")
assert result.content[0].text == "found"
async def test_non_serializable_state_from_middleware_visible_in_resource(self):
server = FastMCP("test")
sentinel = object()
class StateMiddleware(Middleware):
async def on_read_resource(
self, context: MiddlewareContext, call_next: CallNext
) -> Any:
assert context.fastmcp_context is not None
await context.fastmcp_context.set_state(
"obj", sentinel, serializable=False
)
return await call_next(context)
server.add_middleware(StateMiddleware())
@server.resource("test://data")
async def read_it(ctx: Context) -> str:
val = await ctx.get_state("obj")
return "found" if val is sentinel else "missing"
async with Client(server) as client:
result = await client.read_resource("test://data")
assert result[0].text == "found"