Source code for litestar_mcp.plugin

"""Litestar MCP Plugin implementation."""

import logging
from typing import TYPE_CHECKING, Any

from litestar import Litestar, Request, Router
from litestar import get as litestar_get
from litestar.di import Provide
from litestar.handlers import BaseRouteHandler
from litestar.plugins import CLIPlugin, InitPluginProtocol

from litestar_mcp.cli import mcp_group
from litestar_mcp.config import MCPConfig
from litestar_mcp.manifests import build_agent_card, build_oauth_protected_resource
from litestar_mcp.registry import PromptRegistration, Registry
from litestar_mcp.routes import MCPController
from litestar_mcp.schema_builder import generate_schema_for_handler, validate_mcp_header_schema
from litestar_mcp.sse import SubscriptionManager
from litestar_mcp.tasks import MCPTaskStore, TaskRecord
from litestar_mcp.utils import get_handler_function, get_mcp_metadata

_logger = logging.getLogger(__name__)

if TYPE_CHECKING:
    from collections.abc import Callable, Sequence

    from click import Group
    from litestar.config.app import AppConfig


[docs] class LitestarMCP(InitPluginProtocol, CLIPlugin): """Litestar plugin for Model Context Protocol integration."""
[docs] def __init__( self, config: "MCPConfig | None" = None, prompts: "Sequence[Callable[..., Any]] | None" = None, ) -> "None": """Initialize the MCP plugin. Args: config: Plugin configuration. Defaults to ``MCPConfig()``. prompts: Optional sequence of standalone prompt functions decorated with ``@mcp_prompt``. These are registered immediately and made available via ``prompts/list`` and ``prompts/get``. """ self._config = config or MCPConfig() self._registry = Registry() self._dynamic_handlers: list[BaseRouteHandler] = [] if prompts: for fn in prompts: metadata = get_mcp_metadata(fn) or {} if metadata.get("type") != "prompt": msg = f"Function {fn!r} is not decorated with @mcp_prompt" raise ValueError(msg) self._registry.register_prompt( name=metadata["name"], fn=fn, title=metadata.get("title"), description=metadata.get("description"), arguments=metadata.get("arguments"), icons=metadata.get("icons"), ) self._subscription_manager = SubscriptionManager( max_streams=self._config.subscription_max_streams, channels=self._config.subscription_channels, ) self._task_store: MCPTaskStore | None = None if self._config.task_config is not None: task_config = self._config.task_config self._task_store = MCPTaskStore( store=task_config.store, default_ttl_ms=task_config.default_ttl_ms, max_ttl_ms=task_config.max_ttl_ms, poll_interval_ms=task_config.poll_interval_ms, )
@property def config(self) -> "MCPConfig": """Get the plugin configuration.""" return self._config @property def registry(self) -> "Registry": """Get the central registry.""" return self._registry @property def task_store(self) -> "MCPTaskStore | None": """Get the task store.""" return self._task_store @property def discovered_tools(self) -> "dict[str, BaseRouteHandler]": """Get discovered MCP tools.""" return self._registry.tools @property def discovered_resources(self) -> "dict[str, BaseRouteHandler]": """Get discovered MCP resources.""" return self._registry.resources @property def discovered_prompts(self) -> "dict[str, PromptRegistration]": """Get discovered MCP prompts.""" return self._registry.prompts
[docs] def register_dynamic_handler(self, handler: "BaseRouteHandler") -> "None": """Register a dynamic route handler on the plugin. This is typically used by the wrapper class to register decorated tools and resources. """ self._dynamic_handlers.append(handler)
[docs] def on_cli_init(self, cli: "Group") -> "None": """Configure CLI commands for MCP operations.""" cli.add_command(mcp_group)
[docs] def on_app_init(self, app_config: "AppConfig") -> "AppConfig": """Initialize the MCP integration when the Litestar app starts.""" app_config.route_handlers.extend(self._dynamic_handlers) self._discover_mcp_routes(app_config.route_handlers) self._registry.set_subscription_manager(self._subscription_manager) if self._task_store is not None: async def publish_task_status(record: "TaskRecord") -> "None": await self._registry.publish_notification( "notifications/tasks", record.to_dict(), ) self._task_store.set_status_callback(publish_task_status) def provide_mcp_config() -> "MCPConfig": return self._config def provide_registry() -> "Registry": return self._registry def provide_task_store() -> "MCPTaskStore | None": return self._task_store router_kwargs: dict[str, Any] = { "path": self._config.base_path, "route_handlers": [MCPController], "tags": ["mcp"], "include_in_schema": self._config.include_in_schema, "dependencies": { "config": Provide(provide_mcp_config, sync_to_thread=False), "registry": Provide(provide_registry, sync_to_thread=False), "task_store": Provide(provide_task_store, sync_to_thread=False), "discovered_tools": Provide(lambda: self._registry.tools, sync_to_thread=False), "discovered_resources": Provide(lambda: self._registry.resources, sync_to_thread=False), "discovered_prompts": Provide(lambda: self._registry.prompts, sync_to_thread=False), }, } if self._config.guards is not None: router_kwargs["guards"] = self._config.guards mcp_router = Router(**router_kwargs) app_config.route_handlers.append(mcp_router) app_config.on_startup.append(self.on_startup) app_config.on_shutdown.append(self.on_shutdown) @litestar_get( "/.well-known/oauth-protected-resource", sync_to_thread=False, include_in_schema=self._config.include_in_schema, opt={"exclude_from_auth": True}, ) def oauth_protected_resource(request: "Request[Any, Any, Any]") -> "dict[str, Any]": return build_oauth_protected_resource(self._config.auth, request.app) @litestar_get( "/.well-known/agent-card.json", sync_to_thread=False, include_in_schema=self._config.include_in_schema, opt={"exclude_from_auth": True}, ) def agent_card(request: "Request[Any, Any, Any]") -> "dict[str, Any]": return build_agent_card( base_url=str(request.base_url), config=self._config, app=request.app, discovered_tools=self._registry.tools, ) app_config.route_handlers.extend([oauth_protected_resource, agent_card]) return app_config
[docs] def on_startup(self, app: "Litestar") -> "None": """Perform discovery after app is fully initialized and routes are built.""" all_handlers: list[BaseRouteHandler] = [] for route in app.routes: if hasattr(route, "route_handlers"): all_handlers.extend(route.route_handlers) # pyright: ignore[reportAttributeAccessIssue] _logger.debug("Plugin on_startup executing...") self._subscription_manager.start() self._discover_mcp_routes(all_handlers) for handler in self._registry.tools.values(): validate_mcp_header_schema(generate_schema_for_handler(handler)) def invalidate_router() -> "None": _logger.debug("invalidate_router callback triggered") if hasattr(app.state, "mcp_router"): _logger.debug("Deleting mcp_router from app state") delattr(app.state, "mcp_router") self._registry.register_change_callback(invalidate_router) app.state.mcp_router_invalidation_callback = invalidate_router _logger.debug("Registered invalidate_router callback on registry: %s", id(self._registry))
[docs] async def on_shutdown(self, app: "Litestar") -> "None": """Clean up resources on application shutdown.""" _logger.debug("Plugin on_shutdown executing...") callback = getattr(app.state, "mcp_router_invalidation_callback", None) if callback is not None: self._registry.unregister_change_callback(callback) delattr(app.state, "mcp_router_invalidation_callback") _logger.debug("Unregistered invalidate_router callback from registry") await self._subscription_manager.close_all() if self._task_store is not None: await self._task_store.close()
def _discover_mcp_routes(self, route_handlers: "Sequence[Any]") -> "None": """Discover routes marked for MCP exposure via opt attribute or decorators.""" for handler in route_handlers: if isinstance(handler, BaseRouteHandler): metadata = get_mcp_metadata(handler) if not metadata: metadata = get_mcp_metadata(get_handler_function(handler)) if metadata: if metadata["type"] == "tool": self._registry.register_tool(metadata["name"], handler) elif metadata["type"] == "resource": self._registry.register_resource(metadata["name"], handler) template = metadata.get("resource_template") if template is not None: self._registry.register_resource_template(metadata["name"], handler, template) elif metadata["type"] == "prompt": self._registry.register_prompt_handler( metadata["name"], handler, title=metadata.get("title"), description=metadata.get("description"), arguments=metadata.get("arguments"), icons=metadata.get("icons"), ) elif handler.opt: tool_key = self._config.opt_keys.tool resource_key = self._config.opt_keys.resource template_key = self._config.opt_keys.resource_template prompt_key = self._config.opt_keys.prompt if tool_key in handler.opt: self._registry.register_tool(handler.opt[tool_key], handler) if resource_key in handler.opt: resource_name = handler.opt[resource_key] self._registry.register_resource(resource_name, handler) opt_template = handler.opt.get(template_key) if isinstance(opt_template, str): self._registry.register_resource_template(resource_name, handler, opt_template) if prompt_key in handler.opt: opt_keys = self._config.opt_keys self._registry.register_prompt_handler( handler.opt[prompt_key], handler, title=handler.opt.get(opt_keys.prompt_title), description=handler.opt.get(opt_keys.prompt_description), arguments=handler.opt.get(opt_keys.prompt_arguments), icons=handler.opt.get(opt_keys.prompt_icons), ) if getattr(handler, "route_handlers", None): self._discover_mcp_routes(handler.route_handlers) # pyright: ignore[reportAttributeAccessIssue]