From 1f0dcffe967c882a35107438a1c94f2bca723f4f Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Sat, 10 Oct 2026 20:06:44 -0500 Subject: [PATCH 1/2] fix(mcp): support mcp SDK v2 alongside v1 The mcp extra declared mcp>=1.0.0 with no upper bound. mcp 2.x renamed FastMCP to MCPServer and moved it to mcp.server.mcpserver, so a fresh install resolving mcp 2.x made the import fail silently and is_available() report the MCP integration as unusable. - Add a v1/v2 import fallback for FastMCP/Context in server.py, resources.py, and tools.py. - mcp 2.x rejects Context injection on resources with no URI template variables. Pass the toolbar's MCPContext into register_resources directly instead of reading it off ctx.request_context.lifespan_context, removing the need for a ctx parameter on the two static resources (debug://requests, debug://config). - Drop a get_config() reference to a non-existent DebugToolbarConfig.slow_request_threshold_ms attribute that always raised AttributeError when the resource was read; surfaced once the MCPContext type flowed through more precisely after the refactor above. - Raise the mcp lower bound to the oldest version that actually imports cleanly against currently resolvable pydantic (1.21.1); mcp>=1.0.0 through 1.21.0 fail to import under a fresh resolve. Verified end-to-end over stdio with an mcp ClientSession (list_tools, call_tool, list_resources, read_resource) against both mcp 1.22.0 (locked) and mcp 2.3.0 (throwaway uv --with env). --- pyproject.toml | 2 +- src/debug_toolbar/mcp/resources.py | 50 +++++++++++++----------------- src/debug_toolbar/mcp/server.py | 38 ++++++++++++++--------- src/debug_toolbar/mcp/tools.py | 18 +++++++---- tests/unit/mcp/test_server.py | 21 +++++++++++++ 5 files changed, 80 insertions(+), 49 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6ff7ba5..3425260 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,7 +99,7 @@ litestar = [ "litestar>=2.0.0", ] mcp = [ - "mcp>=1.0.0", + "mcp>=1.21.1", ] starlette = [ "starlette>=0.27.0", diff --git a/src/debug_toolbar/mcp/resources.py b/src/debug_toolbar/mcp/resources.py index 34cbed2..66e5f2e 100644 --- a/src/debug_toolbar/mcp/resources.py +++ b/src/debug_toolbar/mcp/resources.py @@ -7,24 +7,29 @@ from uuid import UUID if TYPE_CHECKING: - from debug_toolbar.mcp.server import MCPContext + from mcp.server.fastmcp import FastMCP -try: - from mcp.server.fastmcp import Context, FastMCP -except ImportError: - Context = Any # type: ignore[assignment, misc] - FastMCP = Any # type: ignore[assignment, misc] + from debug_toolbar.mcp.server import MCPContext +else: + try: + from mcp.server.fastmcp import FastMCP + except ImportError: + try: + from mcp.server.mcpserver import MCPServer as FastMCP + except ImportError: + FastMCP = Any # type: ignore[assignment, misc] -def register_resources(mcp: FastMCP) -> None: # noqa: C901 +def register_resources(mcp: FastMCP, mcp_ctx: MCPContext) -> None: # noqa: C901 """Register all debug toolbar resources with the MCP server. Args: mcp: FastMCP server instance. + mcp_ctx: Debug toolbar context holding storage, toolbar, and redaction settings. """ @mcp.resource("debug://requests") - def get_all_requests(ctx: Context) -> str: + def get_all_requests() -> str: """List all tracked requests with summaries. Returns JSON array of request summaries including ID, method, path, @@ -32,7 +37,6 @@ def get_all_requests(ctx: Context) -> str: """ from debug_toolbar.mcp.security import redact_dict - mcp_ctx: MCPContext = ctx.request_context.lifespan_context storage = mcp_ctx.storage requests = [] @@ -57,7 +61,7 @@ def get_all_requests(ctx: Context) -> str: return json.dumps(requests, indent=2, default=str) @mcp.resource("debug://requests/{request_id}") - def get_request_detail(request_id: str, ctx: Context) -> str: + def get_request_detail(request_id: str) -> str: """Get full details for a specific request. Args: @@ -67,7 +71,6 @@ def get_request_detail(request_id: str, ctx: Context) -> str: """ from debug_toolbar.mcp.security import redact_dict - mcp_ctx: MCPContext = ctx.request_context.lifespan_context storage = mcp_ctx.storage data = storage.get(UUID(request_id)) @@ -87,7 +90,7 @@ def get_request_detail(request_id: str, ctx: Context) -> str: return json.dumps(result, indent=2, default=str) @mcp.resource("debug://requests/{request_id}/panels") - def get_request_panels(request_id: str, ctx: Context) -> str: + def get_request_panels(request_id: str) -> str: """Get all panel data for a specific request. Args: @@ -97,7 +100,6 @@ def get_request_panels(request_id: str, ctx: Context) -> str: """ from debug_toolbar.mcp.security import redact_dict - mcp_ctx: MCPContext = ctx.request_context.lifespan_context storage = mcp_ctx.storage data = storage.get(UUID(request_id)) @@ -112,7 +114,7 @@ def get_request_panels(request_id: str, ctx: Context) -> str: return json.dumps(panel_data, indent=2, default=str) @mcp.resource("debug://requests/{request_id}/panels/{panel_name}") - def get_panel_data(request_id: str, panel_name: str, ctx: Context) -> str: + def get_panel_data(request_id: str, panel_name: str) -> str: """Get specific panel data for a request. Args: @@ -123,7 +125,6 @@ def get_panel_data(request_id: str, panel_name: str, ctx: Context) -> str: """ from debug_toolbar.mcp.security import redact_dict - mcp_ctx: MCPContext = ctx.request_context.lifespan_context storage = mcp_ctx.storage data = storage.get(UUID(request_id)) @@ -148,7 +149,7 @@ def get_panel_data(request_id: str, panel_name: str, ctx: Context) -> str: return json.dumps(result, indent=2, default=str) @mcp.resource("debug://requests/{request_id}/sql") - def get_sql_queries(request_id: str, ctx: Context) -> str: + def get_sql_queries(request_id: str) -> str: """Get SQL queries for a specific request. Args: @@ -158,7 +159,6 @@ def get_sql_queries(request_id: str, ctx: Context) -> str: """ from debug_toolbar.mcp.security import redact_dict, redact_sql_parameters - mcp_ctx: MCPContext = ctx.request_context.lifespan_context storage = mcp_ctx.storage data = storage.get(UUID(request_id)) @@ -190,7 +190,7 @@ def get_sql_queries(request_id: str, ctx: Context) -> str: ) @mcp.resource("debug://requests/{request_id}/graphql") - def get_graphql_data(request_id: str, ctx: Context) -> str: + def get_graphql_data(request_id: str) -> str: """Get GraphQL operations for a specific request. Args: @@ -200,7 +200,6 @@ def get_graphql_data(request_id: str, ctx: Context) -> str: """ from debug_toolbar.mcp.security import redact_dict - mcp_ctx: MCPContext = ctx.request_context.lifespan_context storage = mcp_ctx.storage data = storage.get(UUID(request_id)) @@ -219,7 +218,7 @@ def get_graphql_data(request_id: str, ctx: Context) -> str: return json.dumps(graphql_panel, indent=2, default=str) @mcp.resource("debug://requests/{request_id}/alerts") - def get_security_alerts(request_id: str, ctx: Context) -> str: + def get_security_alerts(request_id: str) -> str: """Get security alerts for a specific request. Args: @@ -227,7 +226,6 @@ def get_security_alerts(request_id: str, ctx: Context) -> str: Returns JSON array of security alerts and warnings. """ - mcp_ctx: MCPContext = ctx.request_context.lifespan_context storage = mcp_ctx.storage data = storage.get(UUID(request_id)) @@ -247,7 +245,7 @@ def get_security_alerts(request_id: str, ctx: Context) -> str: ) @mcp.resource("debug://requests/{request_id}/memory") - def get_memory_profile(request_id: str, ctx: Context) -> str: + def get_memory_profile(request_id: str) -> str: """Get memory profiling data for a specific request. Args: @@ -255,7 +253,6 @@ def get_memory_profile(request_id: str, ctx: Context) -> str: Returns JSON object with memory usage and allocation data. """ - mcp_ctx: MCPContext = ctx.request_context.lifespan_context storage = mcp_ctx.storage data = storage.get(UUID(request_id)) @@ -271,7 +268,7 @@ def get_memory_profile(request_id: str, ctx: Context) -> str: return json.dumps(memory_panel, indent=2, default=str) @mcp.resource("debug://requests/{request_id}/async") - def get_async_profile(request_id: str, ctx: Context) -> str: + def get_async_profile(request_id: str) -> str: """Get async task profiling data for a specific request. Args: @@ -279,7 +276,6 @@ def get_async_profile(request_id: str, ctx: Context) -> str: Returns JSON object with async task timeline and blocking call data. """ - mcp_ctx: MCPContext = ctx.request_context.lifespan_context storage = mcp_ctx.storage data = storage.get(UUID(request_id)) @@ -295,14 +291,13 @@ def get_async_profile(request_id: str, ctx: Context) -> str: return json.dumps(async_panel, indent=2, default=str) @mcp.resource("debug://config") - def get_config(ctx: Context) -> str: + def get_config() -> str: """Get current debug toolbar configuration. Returns JSON object with toolbar configuration settings. """ from debug_toolbar.mcp.security import redact_dict - mcp_ctx: MCPContext = ctx.request_context.lifespan_context toolbar = mcp_ctx.toolbar if not toolbar: @@ -313,7 +308,6 @@ def get_config(ctx: Context) -> str: "enabled": config.enabled, "panels": config.panels, "max_request_history": config.max_request_history, - "slow_request_threshold_ms": config.slow_request_threshold_ms, } if hasattr(config, "intercept_redirects"): diff --git a/src/debug_toolbar/mcp/server.py b/src/debug_toolbar/mcp/server.py index f16d529..70395f3 100644 --- a/src/debug_toolbar/mcp/server.py +++ b/src/debug_toolbar/mcp/server.py @@ -8,19 +8,28 @@ from dataclasses import dataclass from typing import TYPE_CHECKING +logger = logging.getLogger(__name__) + if TYPE_CHECKING: + from mcp.server.fastmcp import FastMCP + from debug_toolbar.core.storage import ToolbarStorage from debug_toolbar.core.toolbar import DebugToolbar -logger = logging.getLogger(__name__) + MCP_AVAILABLE = True +else: + try: + from mcp.server.fastmcp import FastMCP -try: - from mcp.server.fastmcp import FastMCP + MCP_AVAILABLE = True + except ImportError: + try: + from mcp.server.mcpserver import MCPServer as FastMCP - MCP_AVAILABLE = True -except ImportError: - MCP_AVAILABLE = False - FastMCP = None # type: ignore[assignment, misc] + MCP_AVAILABLE = True + except ImportError: + MCP_AVAILABLE = False + FastMCP = None # type: ignore[assignment, misc] @dataclass @@ -69,17 +78,18 @@ def create_mcp_server( msg = "MCP support requires the 'mcp' package. Install with: pip install litestar-debug-toolbar[mcp]" raise ImportError(msg) + mcp_ctx = MCPContext( + storage=storage, + toolbar=toolbar, + redact_sensitive=redact_sensitive, + ) + @asynccontextmanager async def lifespan(_server: FastMCP) -> AsyncIterator[MCPContext]: """Manage MCP server lifecycle with debug toolbar context.""" logger.info("Starting debug-toolbar MCP server") - ctx = MCPContext( - storage=storage, - toolbar=toolbar, - redact_sensitive=redact_sensitive, - ) try: - yield ctx + yield mcp_ctx finally: logger.info("Shutting down debug-toolbar MCP server") @@ -92,7 +102,7 @@ async def lifespan(_server: FastMCP) -> AsyncIterator[MCPContext]: from debug_toolbar.mcp.tools import register_tools register_tools(mcp) - register_resources(mcp) + register_resources(mcp, mcp_ctx) return mcp diff --git a/src/debug_toolbar/mcp/tools.py b/src/debug_toolbar/mcp/tools.py index c38ee63..6bb5d74 100644 --- a/src/debug_toolbar/mcp/tools.py +++ b/src/debug_toolbar/mcp/tools.py @@ -6,13 +6,19 @@ from uuid import UUID if TYPE_CHECKING: - from debug_toolbar.mcp.server import MCPContext - -try: from mcp.server.fastmcp import Context, FastMCP -except ImportError: - Context = Any # type: ignore[assignment, misc] - FastMCP = Any # type: ignore[assignment, misc] + + from debug_toolbar.mcp.server import MCPContext +else: + try: + from mcp.server.fastmcp import Context, FastMCP + except ImportError: + try: + from mcp.server.mcpserver import Context + from mcp.server.mcpserver import MCPServer as FastMCP + except ImportError: + Context = Any # type: ignore[assignment, misc] + FastMCP = Any # type: ignore[assignment, misc] SLOW_QUERY_THRESHOLD_MS = 100 SLOW_RESOLVER_THRESHOLD_MS = 50 diff --git a/tests/unit/mcp/test_server.py b/tests/unit/mcp/test_server.py index cb8d750..d48155e 100644 --- a/tests/unit/mcp/test_server.py +++ b/tests/unit/mcp/test_server.py @@ -96,6 +96,27 @@ def test_creates_server_with_toolbar(self) -> None: assert mcp is not None + def test_static_resource_handlers_take_no_parameters(self) -> None: + """Static resources (no URI template variables) must not require injected + parameters, since some MCP SDK versions reject Context injection on them. + """ + import inspect + + from debug_toolbar.core.storage import ToolbarStorage + from debug_toolbar.mcp import create_mcp_server + + storage = ToolbarStorage(max_size=10) + mcp = create_mcp_server(storage) + + static_uris = {"debug://requests", "debug://config"} + resources = mcp._resource_manager._resources + checked = 0 + for uri, resource in resources.items(): + if str(uri) in static_uris: + checked += 1 + assert inspect.signature(resource.fn).parameters == {} + assert checked == len(static_uris) + class TestMcpNotAvailable: """Tests for when MCP is not available.""" From b3e0380ff9be1e55afab5e0479217a90b7b70d82 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Sat, 10 Oct 2026 20:07:36 -0500 Subject: [PATCH 2/2] chore: update lockfile for mcp lower bound --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 4b225c5..a4da854 100644 --- a/uv.lock +++ b/uv.lock @@ -635,7 +635,7 @@ requires-dist = [ { name = "jinja2", specifier = ">=3.1.0" }, { name = "litestar", marker = "extra == 'litestar'", specifier = ">=2.0.0" }, { name = "markupsafe", specifier = ">=2.1.0" }, - { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0.0" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.21.1" }, { name = "sqlalchemy", marker = "extra == 'advanced-alchemy'", specifier = ">=2.0.0" }, { name = "starlette", marker = "extra == 'starlette'", specifier = ">=0.27.0" }, { name = "strawberry-graphql", marker = "extra == 'strawberry'", specifier = ">=0.240.0" },