Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ litestar = [
"litestar>=2.0.0",
]
mcp = [
"mcp>=1.0.0",
"mcp>=1.21.1",
]
starlette = [
"starlette>=0.27.0",
Expand Down
50 changes: 22 additions & 28 deletions src/debug_toolbar/mcp/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,36 @@
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,
status code, and duration.
"""
from debug_toolbar.mcp.security import redact_dict

mcp_ctx: MCPContext = ctx.request_context.lifespan_context
storage = mcp_ctx.storage

requests = []
Expand All @@ -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:
Expand All @@ -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))
Expand All @@ -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:
Expand All @@ -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))
Expand All @@ -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:
Expand All @@ -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))
Expand All @@ -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:
Expand All @@ -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))
Expand Down Expand Up @@ -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:
Expand All @@ -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))
Expand All @@ -219,15 +218,14 @@ 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:
request_id: UUID of the request.

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))
Expand All @@ -247,15 +245,14 @@ 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:
request_id: UUID of the request.

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))
Expand All @@ -271,15 +268,14 @@ 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:
request_id: UUID of the request.

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))
Expand All @@ -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:
Expand All @@ -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"):
Expand Down
38 changes: 24 additions & 14 deletions src/debug_toolbar/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")

Expand All @@ -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

Expand Down
18 changes: 12 additions & 6 deletions src/debug_toolbar/mcp/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/mcp/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading