Files
Pietro Zullo 30b23271d5 fix(python): respect configured inspector path (#1176)
* fix(python): respect configured inspector path

* fix(python): treat inspector path as a base prefix

* fix(python): format inspector fixes for ruff

* fix(ci): run conformance with node 22

* fix(ci): fail conformance runs with empty results

* chore(ci): drop inline conformance result validation

* docs(python): clarify inspector path behavior
2026-03-09 14:24:53 +01:00

616 lines
24 KiB
Python

from __future__ import annotations
import inspect
import logging
import os
import time
import weakref
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, cast
import mcp.server.lowlevel.server as lowlevel
import mcp.server.session as mcp_session
from mcp.server.fastmcp import FastMCP
from mcp.types import (
AnyFunction,
CallToolRequest,
CompleteRequest,
Completion,
GetPromptRequest,
Icon,
ListPromptsRequest,
ListResourcesRequest,
ListToolsRequest,
ReadResourceRequest,
ServerResult,
SetLevelRequest,
SubscribeRequest,
UnsubscribeRequest,
)
# Import auth components
from mcp_use.server.auth import AuthMiddleware, BearerAuthProvider
from mcp_use.server.context import Context as MCPContext
from mcp_use.server.logging import MCPLoggingMiddleware
from mcp_use.server.middleware import (
Middleware,
MiddlewareManager,
ServerMiddlewareContext,
TelemetryMiddleware,
)
from mcp_use.server.middleware.server_session import MiddlewareServerSession
from mcp_use.server.runner import ServerRunner
from mcp_use.server.types import TransportType
from mcp_use.server.utils.inspector import _inspector_index, _inspector_static
from mcp_use.server.utils.json_schema import simplify_optional_schema
from mcp_use.server.utils.routes import docs_ui, openmcp_json
from mcp_use.telemetry.telemetry import Telemetry, telemetry
from mcp_use.telemetry.utils import track_server_run_from_server
# Monkey patching for init request of middleware
mcp_session.ServerSession = MiddlewareServerSession
lowlevel.ServerSession = MiddlewareServerSession
if TYPE_CHECKING:
from mcp.server.session import ServerSession
from mcp_use.server.router import MCPRouter
logger = logging.getLogger(__name__)
_telemetry = Telemetry()
def _normalize_inspector_path(path: str) -> str:
"""Normalize inspector path input into a concrete route ending in `/inspector`."""
if not path or path == "/":
return "/inspector"
normalized = f"/{path.strip('/')}"
if normalized.endswith("/inspector"):
return normalized
return f"{normalized}/inspector"
class MCPServer(FastMCP):
"""Main MCP Server class with integrated inspector and development tools."""
def __init__(
self,
name: str | None = None,
version: str | None = None,
instructions: str | None = None,
icons: list[Icon] | None = None,
auth: BearerAuthProvider | None = None,
middleware: list[Middleware] | None = None,
debug: bool = False,
mcp_path: str = "/mcp",
docs_path: str = "/docs",
inspector_path: str = "/",
openmcp_path: str = "/openmcp.json",
show_inspector_logs: bool = False,
pretty_print_jsonrpc: bool = False,
mcp_logs_only: bool = False,
host: str = "0.0.0.0",
port: int = 8000,
dns_rebinding_protection: bool = False,
):
"""Initialize an MCP server.
Args:
name: Server name for identification
version: Server version string
instructions: Instructions for the AI model using this server
icons: Optional list of icons for the server implementation
middleware: List of middleware to apply to requests
debug: Enable debug mode (adds /docs, /inspector, /openmcp.json endpoints)
mcp_path: Path for MCP endpoint (default: "/mcp")
docs_path: Path for documentation endpoint (default: "/docs")
inspector_path: Base path prefix for the inspector UI; final route is `<prefix>/inspector`.
Examples: `/` -> `/inspector`, `/mcp` -> `/mcp/inspector`.
Passing `/mcp/inspector` is also accepted for backward compatibility.
openmcp_path: Path for OpenMCP metadata (default: "/openmcp.json")
show_inspector_logs: Show inspector-related logs
pretty_print_jsonrpc: Pretty print JSON-RPC messages in logs
mcp_logs_only: Only show MCP protocol logs, suppress HTTP access logs (default: False)
host: Default host for server binding (default: "0.0.0.0"). Can be overridden in run().
port: Default port for server binding (default: 8000). Can be overridden in run().
dns_rebinding_protection: Enable DNS rebinding protection by validating Host/Origin
headers. When True, only requests from localhost origins are accepted.
Recommended for local development servers. Default: False.
"""
self._start_time = time.time()
self._dns_rebinding_protection = dns_rebinding_protection
super().__init__(
name=name or "mcp-use server",
instructions=instructions,
icons=icons,
host=host,
port=port,
)
# Apply DNS rebinding protection if requested
if dns_rebinding_protection:
self._apply_dns_rebinding_protection(host)
if version:
self._mcp_server.version = version
# Register default protocol handlers for full MCP spec compliance
self._register_default_protocol_handlers()
# Store auth provider
self._auth = auth
# Set debug level: DEBUG env var takes precedence, then debug parameter
env_debug_level = self._parse_debug_level()
if env_debug_level > 0:
# Environment variable overrides parameter
self.debug_level = env_debug_level
else:
# Use debug parameter (0 or 1)
self.debug_level = 1 if debug else 0
# Set route paths
self.mcp_path = mcp_path
self.docs_path = docs_path
self.inspector_path = _normalize_inspector_path(inspector_path)
self.openmcp_path = openmcp_path
self.show_inspector_logs = show_inspector_logs
self.pretty_print_jsonrpc = pretty_print_jsonrpc
self.mcp_logs_only = mcp_logs_only
self._transport_type: TransportType = "streamable-http"
self.middleware_manager = MiddlewareManager()
self.middleware_manager.add_middleware(TelemetryMiddleware())
if middleware:
for middleware_instance in middleware:
self.middleware_manager.add_middleware(middleware_instance)
# Add dev routes only in DEBUG=1 and above
if self.debug_level >= 1:
self._add_dev_routes()
self.app = self.streamable_http_app()
# Inject middleware in the ServerSession
MiddlewareServerSession._middleware_manager = self.middleware_manager
MiddlewareServerSession._transport_type = self._transport_type
@property
def debug(self) -> bool:
"""Whether debug mode is enabled."""
return self.debug_level >= 1
def _parse_debug_level(self) -> int:
"""Parse DEBUG environment variable to get debug level.
Returns:
0: Production mode (clean logs only)
1: Debug mode (clean logs + dev routes)
2: Full debug mode (clean logs + dev routes + JSON-RPC logging)
"""
debug_env = os.environ.get("DEBUG", "0")
try:
level = int(debug_env)
return max(0, min(2, level)) # Clamp between 0-2
except ValueError:
# Handle string values
if debug_env.lower() in ("1", "true", "yes"):
return 1
elif debug_env.lower() in ("2", "full", "verbose"):
return 2
else:
return 0
def _add_dev_routes(self):
"""Add development routes for debugging and inspection."""
# OpenMCP configuration
async def openmcp_handler(request):
return await openmcp_json(request, self)
self.custom_route(self.openmcp_path, methods=["GET"])(openmcp_handler)
# Documentation UI
self.custom_route(self.docs_path, methods=["GET"])(docs_ui)
# Inspector routes - wrap to pass mcp_path
async def inspector_index_handler(request):
return await _inspector_index(
request,
mcp_path=self.mcp_path,
inspector_path=self.inspector_path,
)
self.custom_route(self.inspector_path, methods=["GET"])(inspector_index_handler)
self.custom_route(f"{self.inspector_path}/{{path:path}}", methods=["GET"])(_inspector_static)
def add_tool(
self,
fn: AnyFunction,
name: str | None = None,
title: str | None = None,
description: str | None = None,
annotations: Any = None,
icons: Any = None,
meta: dict[str, Any] | None = None,
structured_output: bool | None = None,
) -> None:
"""Register a tool, simplifying Pydantic's nullable ``anyOf`` schemas.
Pydantic emits ``{"anyOf": [{"type": "T"}, {"type": "null"}]}`` for
``Optional[T]`` fields. MCP expresses optionality by omitting the
property from ``required``, so the ``anyOf``/null wrapper is redundant
and breaks description rendering in several MCP clients.
This override lets the upstream ``FastMCP`` build the tool as usual,
then rewrites its ``parameters`` schema into the simpler form.
"""
super().add_tool(
fn,
name=name,
title=title,
description=description,
annotations=annotations,
icons=icons,
meta=meta,
structured_output=structured_output,
)
# The tool was registered under its resolved name — look it up and
# simplify the schema that Pydantic generated.
tool_name = name or fn.__name__
tool = self._tool_manager.get_tool(tool_name)
if tool is not None:
tool.parameters = simplify_optional_schema(tool.parameters)
@telemetry("server_router_used")
def include_router(self, router: MCPRouter, prefix: str = "", enabled: bool = True) -> None:
"""
Include a router's tools, resources, and prompts into this server.
Similar to FastAPI's include_router, this allows you to organize your
MCP server into multiple files/modules.
Args:
router: The MCPRouter instance to include
prefix: Optional prefix to add to all tool names (e.g., "math" -> "math_add")
enabled: Whether to enable this router (default True). Set to False to skip registration.
Example:
```python
from mcp_use.server import MCPServer, MCPRouter
# In routes/math.py
router = MCPRouter()
@router.tool()
def add(a: int, b: int) -> int:
return a + b
# In main.py
server = MCPServer(name="my-server")
server.include_router(router, prefix="math") # Tool becomes "math_add"
server.include_router(other_router, enabled=False) # Skip this router
```
"""
if not enabled:
return
# Register all tools from the router
for tool in router.tools:
tool_name = tool.name or getattr(tool.fn, "__name__", "unknown")
if prefix:
tool_name = f"{prefix}_{tool_name}"
self.add_tool(
tool.fn,
name=tool_name,
title=tool.title,
description=tool.description,
annotations=tool.annotations,
structured_output=tool.structured_output,
)
# Register all resources from the router
for resource in router.resources:
resource_name = resource.name or getattr(resource.fn, "__name__", "unknown")
if prefix:
resource_name = f"{prefix}_{resource_name}"
self.resource(
uri=resource.uri,
name=resource.name,
description=resource.description,
mime_type=resource.mime_type,
)(resource.fn)
# Register all prompts from the router
for prompt in router.prompts:
prompt_name = prompt.name or getattr(prompt.fn, "__name__", "unknown")
if prefix:
prompt_name = f"{prefix}_{prompt_name}"
self.prompt(
name=prompt_name,
description=prompt.description,
)(prompt.fn)
def _apply_dns_rebinding_protection(self, host: str) -> None:
"""Configure transport security to reject non-localhost Host/Origin headers."""
from mcp.server.transport_security import TransportSecuritySettings
localhost_host = host if host in ("127.0.0.1", "localhost", "::1") else "127.0.0.1"
self.settings.transport_security = TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=[f"{localhost_host}:*", "localhost:*", "127.0.0.1:*", "[::1]:*"],
allowed_origins=[
f"http://{localhost_host}:*",
"http://localhost:*",
"http://127.0.0.1:*",
"http://[::1]:*",
],
)
# Reset session manager so it picks up new security settings
self._session_manager = None
def _register_default_protocol_handlers(self) -> None:
"""Register default handlers for MCP protocol methods not covered by FastMCP.
Registers logging/setLevel, resources/subscribe, resources/unsubscribe,
and completion/complete so the server advertises these capabilities and
responds to requests correctly.
"""
# logging/setLevel — store the level so Context.log() can filter
self._client_log_level: str = "debug" # Default: send all levels
@self._mcp_server.set_logging_level()
async def _handle_set_logging_level(level: Any) -> None:
self._client_log_level = str(level)
# resources/subscribe + unsubscribe — track per-URI subscriptions
# Uses WeakSet so that subscriptions are automatically cleaned up when
# a client disconnects and its session object is garbage-collected,
# preventing the memory leak described in GitHub issue #1092.
self._resource_subscriptions: dict[str, weakref.WeakSet] = {} # uri -> WeakSet of sessions
@self._mcp_server.subscribe_resource()
async def _handle_subscribe(uri: Any) -> None:
session = self._current_session()
if session:
self._resource_subscriptions.setdefault(str(uri), weakref.WeakSet()).add(session)
@self._mcp_server.unsubscribe_resource()
async def _handle_unsubscribe(uri: Any) -> None:
session = self._current_session()
if session:
subscribers = self._resource_subscriptions.get(str(uri))
if subscribers:
subscribers.discard(session)
if not subscribers:
del self._resource_subscriptions[str(uri)]
# Patch capabilities to advertise subscribe support
original_get_capabilities = self._mcp_server.get_capabilities
def _patched_get_capabilities(notification_options: Any, experimental_capabilities: Any) -> Any:
caps = original_get_capabilities(notification_options, experimental_capabilities)
if caps.resources is not None:
caps.resources.subscribe = True
return caps
self._mcp_server.get_capabilities = _patched_get_capabilities # type: ignore[assignment]
# completion/complete — default empty completions (override via mcp.completion())
@self._mcp_server.completion()
async def _handle_completion(_ref: Any, _argument: Any, _context: Any = None) -> Completion:
return Completion(values=[], total=0, hasMore=False)
async def notify_resource_updated(self, uri: str) -> None:
"""Notify all subscribed sessions that a resource has been updated.
Broadcasts a ``notifications/resources/updated`` message to every session
that called ``resources/subscribe`` for this URI.
Can be called from within a tool handler or from any async context.
Args:
uri: The URI of the resource that was updated.
"""
subscribers = self._resource_subscriptions.get(uri)
if not subscribers:
# Clean up empty entry left behind after all sessions were GC'd
self._resource_subscriptions.pop(uri, None)
return
# Iterate a snapshot; the WeakSet may shrink during iteration
# if sessions are garbage-collected concurrently.
for session in list(subscribers):
try:
await session.send_resource_updated(uri=uri)
except Exception:
pass # Session may have disconnected
def streamable_http_app(self):
"""Override to add our custom middleware."""
from starlette.middleware.cors import CORSMiddleware
app = super().streamable_http_app()
# Add MCP logging middleware (cast to satisfy type checker)
app.add_middleware(
cast(type, MCPLoggingMiddleware),
debug_level=self.debug_level,
mcp_path=self.mcp_path,
pretty_print_jsonrpc=self.pretty_print_jsonrpc,
)
# Add auth middleware if provider is configured
if self._auth:
app.add_middleware(
AuthMiddleware,
auth_provider=self._auth,
)
logger.debug("AuthMiddleware added to application")
# Add CORS middleware when auth is enabled (handles OPTIONS preflight)
# Note: allow_credentials=False because allow_origins=["*"] - browsers reject the combination
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["*"],
expose_headers=["WWW-Authenticate"],
)
return app
def _wrap_handlers_with_middleware(self) -> None:
handlers = self._mcp_server.request_handlers
if self.debug_level >= 1:
logger.debug(f"Wrapping handlers. Available handlers: {list(handlers.keys())}")
def wrap_request(request_cls: type, method: str) -> None:
if request_cls not in handlers:
return
original = handlers[request_cls]
async def wrapped(request: Any) -> ServerResult:
# Get session ID from HTTP headers if available
session_id = self._get_session_id_from_request()
context = ServerMiddlewareContext(
message=request.params,
method=method,
timestamp=datetime.now(UTC),
transport=self._transport_type,
session_id=session_id,
)
async def call_original(_: ServerMiddlewareContext[Any]) -> Any:
return await original(request)
return await self.middleware_manager.process_request(context, call_original)
handlers[request_cls] = wrapped
wrap_request(CallToolRequest, "tools/call")
wrap_request(ReadResourceRequest, "resources/read")
wrap_request(GetPromptRequest, "prompts/get")
wrap_request(ListToolsRequest, "tools/list")
wrap_request(ListResourcesRequest, "resources/list")
wrap_request(ListPromptsRequest, "prompts/list")
wrap_request(SetLevelRequest, "logging/setLevel")
wrap_request(SubscribeRequest, "resources/subscribe")
wrap_request(UnsubscribeRequest, "resources/unsubscribe")
wrap_request(CompleteRequest, "completion/complete")
def run( # type: ignore[override]
self,
transport: TransportType = "streamable-http",
host: str | None = None,
port: int | None = None,
reload: bool = False,
debug: bool = False,
) -> None:
"""Run the MCP server.
Args:
transport: Transport protocol to use ("stdio", "streamable-http" or "sse")
host: Host to bind to. If provided, overrides __init__ value and reconfigures
DNS rebinding protection accordingly.
port: Port to bind to. If not provided, uses the value from __init__.
reload: Whether to enable auto-reload
debug: Whether to enable debug mode. Overrides the server's debug setting,
adds /docs and /openmcp.json endpoints if not already added.
"""
# Use settings from __init__, run() values override
final_host = host if host is not None else self.settings.host
final_port = port if port is not None else self.settings.port
# If host changed, update settings and rebuild app
if final_host != self.settings.host:
self.settings.host = final_host
self.settings.port = final_port
if self._dns_rebinding_protection:
self._apply_dns_rebinding_protection(final_host)
# Rebuild app with updated settings
self._session_manager = None
self.app = self.streamable_http_app()
# Override debug_level if debug=True is passed to run()
if debug and self.debug_level < 1:
self.debug_level = 1
self._add_dev_routes()
# Rebuild the Starlette app so the new routes are included
self._session_manager = None
self.app = self.streamable_http_app()
self._transport_type = transport
track_server_run_from_server(self, transport, final_host, final_port, _telemetry)
self._wrap_handlers_with_middleware()
runner = ServerRunner(self)
runner.run(transport=transport, host=final_host, port=final_port, reload=reload)
def get_context(self) -> MCPContext: # type: ignore[override]
"""Use the extended MCP-Use context that adds convenience helpers."""
return MCPContext(request_context=self._get_request_context(), fastmcp=self) # type: ignore[override]
def _resource_is_template(self, fn: AnyFunction, uri: str) -> bool:
has_uri_params = "{" in uri and "}" in uri
if has_uri_params:
return True
return bool(inspect.signature(fn).parameters)
def _current_session(self) -> ServerSession | None:
request_context = self._get_request_context()
if request_context is None:
return None
return request_context.session
def _get_request_context(self):
try:
return self._mcp_server.request_context
except LookupError:
return None
def _get_session_id(self) -> str | None:
"""Get session ID from the session object (deprecated - use _get_session_id_from_request)."""
session = self._current_session()
if session is None:
return None
try:
return session.session_id # type: ignore[attr-defined]
except AttributeError:
try:
return session.id # type: ignore[attr-defined]
except AttributeError:
return None
def _get_session_id_from_request(self) -> str | None:
"""Get session ID from HTTP request headers (for Streamable HTTP transport).
The session ID is managed at the transport layer and sent via the
mcp-session-id header according to the MCP specification.
"""
request_context = self._get_request_context()
if request_context is None:
return None
# Try to get the HTTP request from context
request = getattr(request_context, "request", None)
if request is None:
return None
# Extract mcp-session-id header
try:
return request.headers.get("mcp-session-id")
except (AttributeError, KeyError):
return None