From 08ecdb0916dc8e7ff6bb1efb69c7907be1f2e229 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Thu, 14 Aug 2025 19:23:15 -0700 Subject: [PATCH 01/11] Add documentation build section to CONTRIBUTING.md Add comprehensive instructions for building and viewing documentation locally, including macOS Cairo library workaround and development workflow details. --- CONTRIBUTING.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c18937f5b..3ede353be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,6 +64,33 @@ pre-commit run --all-files 9. Submit a pull request to the same branch you branched from +## Building and viewing documentation + +To build and view the documentation locally: + +1. Install documentation dependencies (included with `--dev` flag above): + +```bash +uv sync --frozen --group docs +``` + +2. Serve the documentation locally: + +```bash +uv run mkdocs serve +``` + +**Note for macOS users**: If you encounter a [Cairo library error](https://squidfunk.github.io/mkdocs-material/plugins/requirements/image-processing/#cairo-library-was-not-found), set the library path before running mkdocs: + +```bash +export DYLD_FALLBACK_LIBRARY_PATH=/opt/homebrew/lib +uv run mkdocs serve +``` + +3. Open your browser to `http://127.0.0.1:8000/python-sdk/` to view the documentation + +The documentation will auto-reload when you make changes to files in `docs/`, `mkdocs.yml`, or `src/mcp/`. + ## Code Style - We use `ruff` for linting and formatting From 768ddeb5d42c68243151db0c43ad56ea96f9c76b Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Thu, 14 Aug 2025 19:40:59 -0700 Subject: [PATCH 02/11] fix busted indentation in CONTRIBUTING.md (#2) * fix indentation in CONTRIBUTING.md * fix (more) indentation in CONTRIBUTING.md --- CONTRIBUTING.md | 66 ++++++++++++++++++++++++------------------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3ede353be..a29f3faaa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,15 +10,15 @@ Thank you for your interest in contributing to the MCP Python SDK! This document 4. Clone your fork: `git clone https://github.com/YOUR-USERNAME/python-sdk.git` 5. Install dependencies: -```bash -uv sync --frozen --all-extras --dev -``` + ```bash + uv sync --frozen --all-extras --dev + ``` 6. Set up pre-commit hooks: -```bash -uv tool install pre-commit --with pre-commit-uv --force-reinstall -``` + ```bash + uv tool install pre-commit --with pre-commit-uv --force-reinstall + ``` ## Development Workflow @@ -33,34 +33,34 @@ uv tool install pre-commit --with pre-commit-uv --force-reinstall 4. Ensure tests pass: -```bash -uv run pytest -``` + ```bash + uv run pytest + ``` 5. Run type checking: -```bash -uv run pyright -``` + ```bash + uv run pyright + ``` 6. Run linting: -```bash -uv run ruff check . -uv run ruff format . -``` + ```bash + uv run ruff check . + uv run ruff format . + ``` 7. Update README snippets if you modified example code: -```bash -uv run scripts/update_readme_snippets.py -``` + ```bash + uv run scripts/update_readme_snippets.py + ``` 8. (Optional) Run pre-commit hooks on all files: -```bash -pre-commit run --all-files -``` + ```bash + pre-commit run --all-files + ``` 9. Submit a pull request to the same branch you branched from @@ -70,22 +70,22 @@ To build and view the documentation locally: 1. Install documentation dependencies (included with `--dev` flag above): -```bash -uv sync --frozen --group docs -``` + ```bash + uv sync --frozen --group docs + ``` 2. Serve the documentation locally: -```bash -uv run mkdocs serve -``` + ```bash + uv run mkdocs serve + ``` -**Note for macOS users**: If you encounter a [Cairo library error](https://squidfunk.github.io/mkdocs-material/plugins/requirements/image-processing/#cairo-library-was-not-found), set the library path before running mkdocs: + **Note for macOS users**: If you encounter a [Cairo library error](https://squidfunk.github.io/mkdocs-material/plugins/requirements/image-processing/#cairo-library-was-not-found), set the library path before running mkdocs: -```bash -export DYLD_FALLBACK_LIBRARY_PATH=/opt/homebrew/lib -uv run mkdocs serve -``` + ```bash + export DYLD_FALLBACK_LIBRARY_PATH=/opt/homebrew/lib + uv run mkdocs serve + ``` 3. Open your browser to `http://127.0.0.1:8000/python-sdk/` to view the documentation From b444e53a223eee290611e6e964058879356f28fc Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Thu, 14 Aug 2025 20:28:40 -0700 Subject: [PATCH 03/11] docs: enhanced docstring coverage throughout MCP SDK (YOLOed) (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: improve comprehensive docstring coverage throughout MCP SDK Add extensive docstrings following Google Python Style Guide with Markdown formatting for mkdocs-material compatibility. ## Enhanced documentation for: ### Core framework - **FastMCP**: Main server class with detailed Args/Examples sections - **Context**: Request context with capability descriptions - **Tool/Resource/Prompt**: Base classes with comprehensive attributes ### Client implementation - **ClientSession**: Enhanced call_tool() with extensive examples - Added initialization and core method documentation ### Exception handling - **FastMCPError hierarchy**: Detailed error descriptions and use cases - **McpError**: Protocol error handling documentation ### Utilities and helpers - **RequestContext**: Request metadata and session information - **ToolManager/Resource classes**: Registration and execution docs ## Standards followed: - Google Python Style Guide format (Args/Returns/Raises) - Sentence case headings throughout - Examples properly placed in Examples sections - mkdocs-material compatible Markdown formatting - Type safety maintained (all tests pass) ## Quality assurance: ✅ Code formatted with Ruff ✅ Type checking passes (Pyright) ✅ Linting checks pass ✅ Full test suite passes (528 tests) Improves developer experience with comprehensive API documentation covering public interfaces, usage patterns, and examples. * fix escaped docstring fencing * Revert "fix escaped docstring fencing" This reverts commit 1e25f6036b61b6f239477aeb8927dd97dd019a89. --- CLAUDE.md | 3 + src/mcp/__init__.py | 47 +++++ src/mcp/client/session.py | 127 ++++++++++++- src/mcp/client/session_group.py | 12 +- src/mcp/server/fastmcp/exceptions.py | 40 +++- src/mcp/server/fastmcp/resources/base.py | 36 +++- src/mcp/server/fastmcp/server.py | 181 ++++++++++++++++--- src/mcp/server/fastmcp/tools/base.py | 32 +++- src/mcp/server/fastmcp/tools/tool_manager.py | 25 ++- src/mcp/shared/context.py | 13 ++ src/mcp/shared/exceptions.py | 17 +- 11 files changed, 485 insertions(+), 48 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 186a040cc..40ff9dd51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,3 +132,6 @@ This document contains critical information about working with this codebase. Fo - **Only catch `Exception` for**: - Top-level handlers that must not crash - Cleanup blocks (log at debug level) + +- Always use sentence case for all headings and heading-like text in any Markdown-formatted content, including docstrings. +- Example snippets in docsstrings MUST only appear within the Examples section of the docstring. You MAY include multiple examples in the Examples section. \ No newline at end of file diff --git a/src/mcp/__init__.py b/src/mcp/__init__.py index e93b95c90..e4f70f82a 100644 --- a/src/mcp/__init__.py +++ b/src/mcp/__init__.py @@ -1,3 +1,50 @@ +"""MCP Python SDK - Model Context Protocol implementation for Python. + +The Model Context Protocol (MCP) allows applications to provide context for LLMs in a +standardized way, separating the concerns of providing context from the actual LLM +interaction. This Python SDK implements the full MCP specification, making it easy to: + +- Build MCP clients that can connect to any MCP server +- Create MCP servers that expose resources, prompts and tools +- Use standard transports like stdio, SSE, and Streamable HTTP +- Handle all MCP protocol messages and lifecycle events + +## Quick start - creating a server + +```python +from mcp.server.fastmcp import FastMCP + +# Create an MCP server +mcp = FastMCP("Demo") + +@mcp.tool() +def add(a: int, b: int) -> int: + \"\"\"Add two numbers\"\"\" + return a + b + +if __name__ == "__main__": + mcp.run() +``` + +## Quick start - creating a client + +```python +from mcp import ClientSession, StdioServerParameters, stdio_client + +server_params = StdioServerParameters( + command="python", args=["server.py"] +) + +async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + tools = await session.list_tools() + result = await session.call_tool("add", {"a": 5, "b": 3}) +``` + +For more examples and documentation, see: https://modelcontextprotocol.io +""" + from .client.session import ClientSession from .client.session_group import ClientSessionGroup from .client.stdio import StdioServerParameters, stdio_client diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 1853ce7c1..4f0d0bbec 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -107,6 +107,46 @@ class ClientSession( types.ServerNotification, ] ): + """A client session for communicating with an MCP server. + + This class provides a high-level interface for MCP client operations, including + tool calling, resource management, prompt handling, and protocol initialization. + It manages the bidirectional communication channel with an MCP server and handles + protocol-level concerns like message validation and capability negotiation. + + The session supports various MCP capabilities: + + - Tool execution with structured output validation + - Resource access and subscription management + - Prompt template retrieval and completion + - Progress notifications and logging + - Custom sampling, elicitation, and root listing callbacks + + Args: + read_stream: Stream for receiving messages from the server. + write_stream: Stream for sending messages to the server. + read_timeout_seconds: Optional timeout for read operations. + sampling_callback: Optional callback for handling sampling requests from the server. + elicitation_callback: Optional callback for handling elicitation requests from the server. + list_roots_callback: Optional callback for handling root listing requests from the server. + logging_callback: Optional callback for handling log messages from the server. + message_handler: Optional custom handler for incoming messages and exceptions. + client_info: Optional client implementation information. + + Example: + ```python + async with create_client_session() as session: + # Initialize the session + await session.initialize() + + # List available tools + tools = await session.list_tools() + + # Call a tool + result = await session.call_tool("my_tool", {"arg": "value"}) + ``` + """ + def __init__( self, read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], @@ -135,6 +175,17 @@ def __init__( self._tool_output_schemas: dict[str, dict[str, Any] | None] = {} async def initialize(self) -> types.InitializeResult: + """Initialize the MCP session with the server. + + Sends an initialization request to establish capabilities and protocol version. + This must be called before any other operations can be performed. + + Returns: + Server's initialization response containing capabilities and metadata + + Raises: + McpError: If initialization fails or protocol version is unsupported + """ sampling = types.SamplingCapability() if self._sampling_callback is not _default_sampling_callback else None elicitation = ( types.ElicitationCapability() if self._elicitation_callback is not _default_elicitation_callback else None @@ -288,7 +339,81 @@ async def call_tool( read_timeout_seconds: timedelta | None = None, progress_callback: ProgressFnT | None = None, ) -> types.CallToolResult: - """Send a tools/call request with optional progress callback support.""" + """Execute a tool on the connected MCP server. + + This method sends a tools/call request to execute a specific tool with provided + arguments. The server will validate the arguments against the tool's input schema + and return structured or unstructured content based on the tool's configuration. + + For tools that return structured output, the result will be automatically validated + against the tool's output schema if one is defined. Tools may also return various + content types including text, images, and embedded resources. + + Args: + name: The name of the tool to execute. Must match a tool exposed by the server. + arguments: Optional dictionary of arguments to pass to the tool. The structure + must match the tool's input schema. Defaults to None for tools that don't + require arguments. + read_timeout_seconds: Optional timeout for the tool execution. If not specified, + uses the session's default read timeout. Useful for long-running tools. + progress_callback: Optional callback function to receive progress updates during + tool execution. The callback receives progress notifications as they're sent + by the server. + + Returns: + CallToolResult containing the tool's response. The result includes: + - content: List of content blocks (text, images, embedded resources) + - structuredContent: Validated structured data if the tool has an output schema + - isError: Boolean indicating if the tool execution failed + + Raises: + RuntimeError: If the tool returns structured content that doesn't match its + output schema, or if the tool name is not found on the server. + ValidationError: If the provided arguments don't match the tool's input schema. + TimeoutError: If the tool execution exceeds the specified timeout. + + Example: + ```python + # Simple tool call without arguments + result = await session.call_tool("ping") + + # Tool call with arguments + result = await session.call_tool("add", {"a": 5, "b": 3}) + + # Access text content + for content in result.content: + if isinstance(content, types.TextContent): + print(content.text) + + # Access structured output (if available) + if result.structuredContent: + user_data = result.structuredContent + print(f"Result: {user_data}") + + # Handle tool execution errors + if result.isError: + print("Tool execution failed") + + # Long-running tool with progress tracking + def on_progress(progress_token, progress, total, message): + percent = (progress / total) * 100 if total else 0 + print(f"Progress: {percent:.1f}% - {message}") + + result = await session.call_tool( + "long_task", + {"steps": 10}, + read_timeout_seconds=timedelta(minutes=5), + progress_callback=on_progress + ) + ``` + + Note: + Tools may return different content types: + - TextContent: Plain text responses + - ImageContent: Generated images with MIME type and binary data + - EmbeddedResource: File contents or external resources + - Structured data via structuredContent when output schema is defined + """ result = await self.send_request( types.ClientRequest( diff --git a/src/mcp/client/session_group.py b/src/mcp/client/session_group.py index 700b5417f..79fb702d6 100644 --- a/src/mcp/client/session_group.py +++ b/src/mcp/client/session_group.py @@ -75,12 +75,14 @@ class ClientSessionGroup: the client and can be accessed via the session. Example Usage: - name_fn = lambda name, server_info: f"{(server_info.name)}_{name}" - async with ClientSessionGroup(component_name_hook=name_fn) as group: - for server_params in server_params: - await group.connect_to_server(server_param) - ... + ```python + name_fn = lambda name, server_info: f"{(server_info.name)}_{name}" + async with ClientSessionGroup(component_name_hook=name_fn) as group: + for server_params in server_params: + await group.connect_to_server(server_param) + # ... + ``` """ class _ComponentNames(BaseModel): diff --git a/src/mcp/server/fastmcp/exceptions.py b/src/mcp/server/fastmcp/exceptions.py index fb5bda106..2bd2cca8e 100644 --- a/src/mcp/server/fastmcp/exceptions.py +++ b/src/mcp/server/fastmcp/exceptions.py @@ -2,20 +2,50 @@ class FastMCPError(Exception): - """Base error for FastMCP.""" + """Base exception class for all FastMCP-related errors. + + This is the root exception type for all errors that can occur within + the FastMCP framework. Specific error types inherit from this class. + """ class ValidationError(FastMCPError): - """Error in validating parameters or return values.""" + """Raised when parameter or return value validation fails. + + This exception is raised when input arguments don't match a tool's + input schema, or when output values fail validation against output schemas. + It typically indicates incorrect data types, missing required fields, + or values that don't meet schema constraints. + """ class ResourceError(FastMCPError): - """Error in resource operations.""" + """Raised when resource operations fail. + + This exception is raised for resource-related errors such as: + - Resource not found for a given URI + - Resource content cannot be read or generated + - Resource template parameter validation failures + - Resource access permission errors + """ class ToolError(FastMCPError): - """Error in tool operations.""" + """Raised when tool operations fail. + + This exception is raised for tool-related errors such as: + - Tool not found for a given name + - Tool execution failures or unhandled exceptions + - Tool registration conflicts or validation errors + - Tool parameter or result processing errors + """ class InvalidSignature(Exception): - """Invalid signature for use with FastMCP.""" + """Raised when a function signature is incompatible with FastMCP. + + This exception is raised when trying to register a function as a tool, + resource, or prompt that has an incompatible signature. This can occur + when functions have unsupported parameter types, complex annotations + that cannot be converted to JSON schema, or other signature issues. + """ diff --git a/src/mcp/server/fastmcp/resources/base.py b/src/mcp/server/fastmcp/resources/base.py index f57631cc1..660436ab6 100644 --- a/src/mcp/server/fastmcp/resources/base.py +++ b/src/mcp/server/fastmcp/resources/base.py @@ -15,7 +15,19 @@ class Resource(BaseModel, abc.ABC): - """Base class for all resources.""" + """Base class for all MCP resources. + + Resources provide contextual data that can be read by LLMs. Each resource + has a URI, optional metadata like name and description, and content that + can be retrieved via the read() method. + + Attributes: + uri: Unique identifier for the resource + name: Optional name for the resource (defaults to URI if not provided) + title: Optional human-readable title + description: Optional description of the resource content + mime_type: MIME type of the resource content (defaults to text/plain) + """ model_config = ConfigDict(validate_default=True) @@ -32,7 +44,18 @@ class Resource(BaseModel, abc.ABC): @field_validator("name", mode="before") @classmethod def set_default_name(cls, name: str | None, info: ValidationInfo) -> str: - """Set default name from URI if not provided.""" + """Set default name from URI if not provided. + + Args: + name: The provided name value + info: Pydantic validation info containing other field values + + Returns: + The name to use for the resource + + Raises: + ValueError: If neither name nor uri is provided + """ if name: return name if uri := info.data.get("uri"): @@ -41,5 +64,12 @@ def set_default_name(cls, name: str | None, info: ValidationInfo) -> str: @abc.abstractmethod async def read(self) -> str | bytes: - """Read the resource content.""" + """Read the resource content. + + Returns: + The resource content as either a string or bytes + + Raises: + ResourceError: If the resource cannot be read + """ pass diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 924baaa9b..e0e8fa451 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -119,6 +119,84 @@ async def wrap(_: MCPServer[LifespanResultT, Request]) -> AsyncIterator[Lifespan class FastMCP(Generic[LifespanResultT]): + """FastMCP - A high-level, ergonomic interface for creating MCP servers. + + FastMCP provides a decorator-based API for building MCP servers with automatic + parameter validation, structured output support, and built-in transport handling. + It supports stdio, SSE, and Streamable HTTP transports out of the box. + + Features include automatic validation using Pydantic, structured output conversion, + context injection for MCP capabilities, lifespan management, multiple transport + support, and built-in OAuth 2.1 authentication. + + Args: + name: Human-readable name for the server. If None, defaults to "FastMCP" + instructions: Optional instructions/description for the server + auth_server_provider: OAuth authorization server provider for authentication + token_verifier: Token verifier for validating OAuth tokens + event_store: Event store for Streamable HTTP transport persistence + tools: Pre-configured tools to register with the server + debug: Enable debug mode for additional logging + log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) + host: Host address for HTTP transports + port: Port number for HTTP transports + mount_path: Base mount path for SSE transport + sse_path: Path for SSE endpoint + message_path: Path for message endpoint + streamable_http_path: Path for Streamable HTTP endpoint + json_response: Whether to use JSON responses instead of SSE for Streamable HTTP + stateless_http: Whether to operate in stateless mode for Streamable HTTP + warn_on_duplicate_resources: Whether to warn when duplicate resources are registered + warn_on_duplicate_tools: Whether to warn when duplicate tools are registered + warn_on_duplicate_prompts: Whether to warn when duplicate prompts are registered + dependencies: List of package dependencies (currently unused) + lifespan: Async context manager for server startup/shutdown lifecycle + auth: Authentication settings for OAuth 2.1 support + transport_security: Transport security settings + + Examples: + Basic server creation: + + ```python + from mcp.server.fastmcp import FastMCP + + # Create a server + mcp = FastMCP("My Server") + + # Add a tool + @mcp.tool() + def add_numbers(a: int, b: int) -> int: + \"\"\"Add two numbers together.\"\"\" + return a + b + + # Add a resource + @mcp.resource("greeting://{name}") + def get_greeting(name: str) -> str: + \"\"\"Get a personalized greeting.\"\"\" + return f"Hello, {name}!" + + # Run the server + if __name__ == "__main__": + mcp.run() + ``` + + Server with authentication: + + ```python + from mcp.server.auth.settings import AuthSettings + from pydantic import AnyHttpUrl + + mcp = FastMCP( + "Protected Server", + auth=AuthSettings( + issuer_url=AnyHttpUrl("https://auth.example.com"), + resource_server_url=AnyHttpUrl("http://localhost:8000"), + required_scopes=["read", "write"] + ) + ) + ``` + """ + def __init__( self, name: str | None = None, @@ -282,9 +360,21 @@ async def list_tools(self) -> list[MCPTool]: ] def get_context(self) -> Context[ServerSession, LifespanResultT, Request]: - """ - Returns a Context object. Note that the context will only be valid - during a request; outside a request, most methods will error. + """Get the current request context for accessing MCP capabilities. + + The context provides access to logging, progress reporting, resource reading, + user interaction, and request metadata. It's only valid during request + processing - calling context methods outside of a request will raise errors. + + Returns: + Context object for the current request with access to MCP capabilities. + + Raises: + LookupError: If called outside of a request context. + + Note: + This method should typically only be called from within tool, resource, + or prompt handlers where a request context is active. """ try: request_context = self._mcp_server.request_context @@ -293,12 +383,29 @@ def get_context(self) -> Context[ServerSession, LifespanResultT, Request]: return Context(request_context=request_context, fastmcp=self) async def call_tool(self, name: str, arguments: dict[str, Any]) -> Sequence[ContentBlock] | dict[str, Any]: - """Call a tool by name with arguments.""" + """Call a registered tool by name with the provided arguments. + + Args: + name: Name of the tool to call + arguments: Dictionary of arguments to pass to the tool + + Returns: + Tool execution result, either as content blocks or structured data + + Raises: + ToolError: If the tool is not found or execution fails + ValidationError: If the arguments don't match the tool's schema + """ context = self.get_context() return await self._tool_manager.call_tool(name, arguments, context=context, convert_result=True) async def list_resources(self) -> list[MCPResource]: - """List all available resources.""" + """List all available resources registered with this server. + + Returns: + List of MCP Resource objects containing URI, name, description, and MIME type + information for each registered resource. + """ resources = self._resource_manager.list_resources() return [ @@ -313,6 +420,15 @@ async def list_resources(self) -> list[MCPResource]: ] async def list_resource_templates(self) -> list[MCPResourceTemplate]: + """List all available resource templates registered with this server. + + Resource templates define URI patterns that can be dynamically resolved + with different parameters to access multiple related resources. + + Returns: + List of MCP ResourceTemplate objects containing URI templates, names, + and descriptions for each registered resource template. + """ templates = self._resource_manager.list_templates() return [ MCPResourceTemplate( @@ -325,7 +441,17 @@ async def list_resource_templates(self) -> list[MCPResourceTemplate]: ] async def read_resource(self, uri: AnyUrl | str) -> Iterable[ReadResourceContents]: - """Read a resource by URI.""" + """Read the contents of a resource by its URI. + + Args: + uri: The URI of the resource to read + + Returns: + Iterable of ReadResourceContents containing the resource data + + Raises: + ResourceError: If the resource is not found or cannot be read + """ resource = await self._resource_manager.get_resource(uri) if not resource: @@ -1011,33 +1137,36 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT, RequestT]): This provides a cleaner interface to MCP's RequestContext functionality. It gets injected into tool and resource functions that request it via type hints. + The context provides access to logging, progress reporting, resource reading, + user interaction, and request metadata. - To use context in a tool function, add a parameter with the Context type annotation: + The context parameter name can be anything as long as it's annotated with Context. + The context is optional - tools that don't need it can omit the parameter. - ```python - @server.tool() - def my_tool(x: int, ctx: Context) -> str: - # Log messages to the client - ctx.info(f"Processing {x}") - ctx.debug("Debug info") - ctx.warning("Warning message") - ctx.error("Error message") + Examples: + Using context in a tool function: - # Report progress - ctx.report_progress(50, 100) + ```python + @server.tool() + def my_tool(x: int, ctx: Context) -> str: + # Log messages to the client + ctx.info(f"Processing {x}") + ctx.debug("Debug info") + ctx.warning("Warning message") + ctx.error("Error message") - # Access resources - data = ctx.read_resource("resource://data") + # Report progress + ctx.report_progress(50, 100) - # Get request info - request_id = ctx.request_id - client_id = ctx.client_id + # Access resources + data = ctx.read_resource("resource://data") - return str(x) - ``` + # Get request info + request_id = ctx.request_id + client_id = ctx.client_id - The context parameter name can be anything as long as it's annotated with Context. - The context is optional - tools that don't need it can omit the parameter. + return str(x) + ``` """ _request_context: RequestContext[ServerSessionT, LifespanContextT, RequestT] | None diff --git a/src/mcp/server/fastmcp/tools/base.py b/src/mcp/server/fastmcp/tools/base.py index f50126081..24716a21f 100644 --- a/src/mcp/server/fastmcp/tools/base.py +++ b/src/mcp/server/fastmcp/tools/base.py @@ -48,7 +48,23 @@ def from_function( annotations: ToolAnnotations | None = None, structured_output: bool | None = None, ) -> Tool: - """Create a Tool from a function.""" + """Create a Tool from a function. + + Args: + fn: The function to wrap as a tool + name: Optional name for the tool (defaults to function name) + title: Optional human-readable title for the tool + description: Optional description (defaults to function docstring) + context_kwarg: Name of parameter that should receive the Context object + annotations: Optional tool annotations for additional metadata + structured_output: Whether to enable structured output for this tool + + Returns: + Tool instance configured from the function + + Raises: + ValueError: If the function is a lambda without a provided name + """ from mcp.server.fastmcp.server import Context func_name = name or fn.__name__ @@ -93,7 +109,19 @@ async def run( context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None, convert_result: bool = False, ) -> Any: - """Run the tool with arguments.""" + """Run the tool with the provided arguments. + + Args: + arguments: Dictionary of arguments to pass to the tool function + context: Optional MCP context for accessing capabilities + convert_result: Whether to convert the result using the function metadata + + Returns: + The tool's execution result, potentially converted based on convert_result + + Raises: + ToolError: If tool execution fails or validation errors occur + """ try: result = await self.fn_metadata.call_fn_with_arg_validation( self.fn, diff --git a/src/mcp/server/fastmcp/tools/tool_manager.py b/src/mcp/server/fastmcp/tools/tool_manager.py index bfa8b2382..e0c6901a9 100644 --- a/src/mcp/server/fastmcp/tools/tool_manager.py +++ b/src/mcp/server/fastmcp/tools/tool_manager.py @@ -17,7 +17,15 @@ class ToolManager: - """Manages FastMCP tools.""" + """Manages registration and execution of FastMCP tools. + + The ToolManager handles tool registration, validation, and execution. + It maintains a registry of tools and provides methods for adding, + retrieving, and calling tools. + + Attributes: + warn_on_duplicate_tools: Whether to warn when duplicate tools are registered + """ def __init__( self, @@ -35,11 +43,22 @@ def __init__( self.warn_on_duplicate_tools = warn_on_duplicate_tools def get_tool(self, name: str) -> Tool | None: - """Get tool by name.""" + """Get a registered tool by name. + + Args: + name: Name of the tool to retrieve + + Returns: + Tool instance if found, None otherwise + """ return self._tools.get(name) def list_tools(self) -> list[Tool]: - """List all registered tools.""" + """List all registered tools. + + Returns: + List of all Tool instances registered with this manager + """ return list(self._tools.values()) def add_tool( diff --git a/src/mcp/shared/context.py b/src/mcp/shared/context.py index f3006e7d5..483681fdd 100644 --- a/src/mcp/shared/context.py +++ b/src/mcp/shared/context.py @@ -13,6 +13,19 @@ @dataclass class RequestContext(Generic[SessionT, LifespanContextT, RequestT]): + """Context object containing information about the current MCP request. + + This context is available during request processing and provides access + to the request metadata, session, and any lifespan-scoped resources. + + Attributes: + request_id: Unique identifier for the current request + meta: Optional metadata from the request including progress token + session: The MCP session handling this request + lifespan_context: Application-specific context from lifespan initialization + request: The original request object, if available + """ + request_id: RequestId meta: RequestParams.Meta | None session: SessionT diff --git a/src/mcp/shared/exceptions.py b/src/mcp/shared/exceptions.py index 97a1c09a9..eb8999a92 100644 --- a/src/mcp/shared/exceptions.py +++ b/src/mcp/shared/exceptions.py @@ -2,13 +2,24 @@ class McpError(Exception): - """ - Exception type raised when an error arrives over an MCP connection. + """Exception raised when an MCP protocol error is received from a peer. + + This exception is raised when the remote MCP peer returns an error response + instead of a successful result. It wraps the ErrorData received from the peer + and provides access to the error code, message, and any additional data. + + Attributes: + error: The ErrorData object received from the MCP peer containing + error code, message, and optional additional data """ error: ErrorData def __init__(self, error: ErrorData): - """Initialize McpError.""" + """Initialize McpError with error data from the MCP peer. + + Args: + error: ErrorData object containing the error details from the peer + """ super().__init__(error.message) self.error = error From 740434dde02d478ced2d4e2ad8fe9d6aeaff4120 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Thu, 14 Aug 2025 20:53:49 -0700 Subject: [PATCH 04/11] enable API ref left nav autogen (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix griffe warnings in mkdocs documentation generation - Remove **extra parameter documentation that doesn't match function signature in fastmcp/server.py - Fix malformed docstring structure in func_metadata.py by moving content to proper Note section - Correct parameter name mismatch in lowlevel/server.py lifespan function docstring 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * autgenerate API reference nav --------- Co-authored-by: Claude --- docs/api.md | 1 - docs/index.md | 2 -- mkdocs.yml | 3 ++- pyproject.toml | 1 + src/mcp/server/fastmcp/server.py | 1 - .../server/fastmcp/utilities/func_metadata.py | 9 +++++---- src/mcp/server/lowlevel/server.py | 2 +- uv.lock | 16 ++++++++++++++++ 8 files changed, 25 insertions(+), 10 deletions(-) delete mode 100644 docs/api.md diff --git a/docs/api.md b/docs/api.md deleted file mode 100644 index 3f696af54..000000000 --- a/docs/api.md +++ /dev/null @@ -1 +0,0 @@ -::: mcp diff --git a/docs/index.md b/docs/index.md index 42ad9ca0c..aefa2a6ab 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,5 +1,3 @@ # MCP Server This is the MCP Server implementation in Python. - -It only contains the [API Reference](api.md) for the time being. diff --git a/mkdocs.yml b/mkdocs.yml index b907cb873..1aba72cbd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,7 +12,6 @@ site_url: https://modelcontextprotocol.github.io/python-sdk nav: - Home: index.md - - API Reference: api.md theme: name: "material" @@ -118,3 +117,5 @@ plugins: - url: https://docs.python.org/3/objects.inv - url: https://docs.pydantic.dev/latest/objects.inv - url: https://typing-extensions.readthedocs.io/en/latest/objects.inv + - api-autonav: + modules: ['src/mcp'] \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 9b84c5815..73c6f6148 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ dev = [ ] docs = [ "mkdocs>=1.6.1", + "mkdocs-api-autonav>=0.3.1", "mkdocs-glightbox>=0.4.0", "mkdocs-material[imaging]>=9.5.45", "mkdocstrings-python>=1.12.2", diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index e0e8fa451..fd80721c8 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -1275,7 +1275,6 @@ async def log( level: Log level (debug, info, warning, error) message: Log message logger_name: Optional logger name - **extra: Additional structured data to include """ await self.request_context.session.send_log_message( level=level, diff --git a/src/mcp/server/fastmcp/utilities/func_metadata.py b/src/mcp/server/fastmcp/utilities/func_metadata.py index 70be8796d..256f63749 100644 --- a/src/mcp/server/fastmcp/utilities/func_metadata.py +++ b/src/mcp/server/fastmcp/utilities/func_metadata.py @@ -185,11 +185,12 @@ def func_metadata( func: The function to convert to a pydantic model skip_names: A list of parameter names to skip. These will not be included in the model. - structured_output: Controls whether the tool's output is structured or unstructured - - If None, auto-detects based on the function's return type annotation - - If True, unconditionally creates a structured tool (return type annotation permitting) - - If False, unconditionally creates an unstructured tool + structured_output: Controls whether the tool's output is structured or unstructured. + If None, auto-detects based on the function's return type annotation. + If True, unconditionally creates a structured tool (return type annotation permitting). + If False, unconditionally creates an unstructured tool. + Note: If structured, creates a Pydantic model for the function's result based on its annotation. Supports various return types: - BaseModel subclasses (used directly) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 8c459383c..c8375f63d 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -122,7 +122,7 @@ async def lifespan(_: Server[LifespanResultT, RequestT]) -> AsyncIterator[dict[s """Default lifespan context manager that does nothing. Args: - server: The server instance this lifespan is managing + _: The server instance this lifespan is managing Returns: An empty context object diff --git a/uv.lock b/uv.lock index 59192bee0..b8802eeba 100644 --- a/uv.lock +++ b/uv.lock @@ -619,6 +619,7 @@ dev = [ ] docs = [ { name = "mkdocs" }, + { name = "mkdocs-api-autonav" }, { name = "mkdocs-glightbox" }, { name = "mkdocs-material", extra = ["imaging"] }, { name = "mkdocstrings-python" }, @@ -659,6 +660,7 @@ dev = [ ] docs = [ { name = "mkdocs", specifier = ">=1.6.1" }, + { name = "mkdocs-api-autonav", specifier = ">=0.3.1" }, { name = "mkdocs-glightbox", specifier = ">=0.4.0" }, { name = "mkdocs-material", extras = ["imaging"], specifier = ">=9.5.45" }, { name = "mkdocstrings-python", specifier = ">=1.12.2" }, @@ -931,6 +933,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, ] +[[package]] +name = "mkdocs-api-autonav" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mkdocs" }, + { name = "mkdocstrings-python" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/9f/c73e0b79b9be34f3dd975e7ba175ef6397a986f470f9aafac491d53699f8/mkdocs_api_autonav-0.3.1.tar.gz", hash = "sha256:5d37ad53a03600acff0f7d67fad122a38800d172777d3c4f8c0dfbb9b58e8c29", size = 15980, upload-time = "2025-08-08T04:08:50.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/60/5acc016c75cac9758eff0cbf032d2504c8baca701d5ea4a784932e4764af/mkdocs_api_autonav-0.3.1-py3-none-any.whl", hash = "sha256:363cdf24ec12670971049291b72806ee55ae6560611ffd6ed2fdeb69c43e6d4f", size = 12033, upload-time = "2025-08-08T04:08:48.349Z" }, +] + [[package]] name = "mkdocs-autorefs" version = "1.4.2" From 920888089c12a4bf9b460298ddb9bf232db3e461 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Sat, 16 Aug 2025 16:59:11 -0700 Subject: [PATCH 05/11] Doc site work (#5) * site config (URL, site name, API ref tweaks) * Shard README into *.md in docs/ * minor quickstart tweak --- docs/asgi-integration.md | 762 +++++++++++++++++++ docs/authentication.md | 596 +++++++++++++++ docs/completions.md | 1053 ++++++++++++++++++++++++++ docs/context.md | 654 ++++++++++++++++ docs/display-utilities.md | 1287 ++++++++++++++++++++++++++++++++ docs/elicitation.md | 442 +++++++++++ docs/images.md | 780 ++++++++++++++++++++ docs/index.md | 83 ++- docs/installation.md | 194 +++++ docs/low-level-server.md | 1299 ++++++++++++++++++++++++++++++++ docs/oauth-clients.md | 971 ++++++++++++++++++++++++ docs/parsing-results.md | 1333 +++++++++++++++++++++++++++++++++ docs/progress-logging.md | 226 ++++++ docs/prompts.md | 562 ++++++++++++++ docs/quickstart.md | 140 ++++ docs/resources.md | 487 ++++++++++++ docs/running-servers.md | 666 +++++++++++++++++ docs/sampling.md | 628 ++++++++++++++++ docs/servers.md | 353 +++++++++ docs/streamable-http.md | 722 ++++++++++++++++++ docs/structured-output.md | 1477 +++++++++++++++++++++++++++++++++++++ docs/tools.md | 671 +++++++++++++++++ docs/writing-clients.md | 1078 +++++++++++++++++++++++++++ mkdocs.yml | 52 +- src/mcp/__init__.py | 15 +- 25 files changed, 16508 insertions(+), 23 deletions(-) create mode 100644 docs/asgi-integration.md create mode 100644 docs/authentication.md create mode 100644 docs/completions.md create mode 100644 docs/context.md create mode 100644 docs/display-utilities.md create mode 100644 docs/elicitation.md create mode 100644 docs/images.md create mode 100644 docs/installation.md create mode 100644 docs/low-level-server.md create mode 100644 docs/oauth-clients.md create mode 100644 docs/parsing-results.md create mode 100644 docs/progress-logging.md create mode 100644 docs/prompts.md create mode 100644 docs/quickstart.md create mode 100644 docs/resources.md create mode 100644 docs/running-servers.md create mode 100644 docs/sampling.md create mode 100644 docs/servers.md create mode 100644 docs/streamable-http.md create mode 100644 docs/structured-output.md create mode 100644 docs/tools.md create mode 100644 docs/writing-clients.md diff --git a/docs/asgi-integration.md b/docs/asgi-integration.md new file mode 100644 index 000000000..268a4ff4c --- /dev/null +++ b/docs/asgi-integration.md @@ -0,0 +1,762 @@ +# ASGI integration + +Learn how to integrate MCP servers with existing ASGI applications like FastAPI, Starlette, Django, and others. + +## Overview + +ASGI integration allows you to: + +- **Mount MCP servers** in existing web applications +- **Share middleware** and authentication between HTTP and MCP endpoints +- **Unified deployment** - serve both web API and MCP from the same process +- **Resource sharing** - use the same database connections and services + +## FastAPI integration + +### Basic integration + +```python +""" +FastAPI application with embedded MCP server. +""" + +from fastapi import FastAPI, HTTPException +from mcp.server.fastmcp import FastMCP + +# Create FastAPI app +app = FastAPI(title="API with MCP Integration") + +# Create MCP server +mcp = FastMCP("FastAPI MCP Server", stateless_http=True) + +@mcp.tool() +def process_api_data(data: str, operation: str = "uppercase") -> str: + """Process data with various operations.""" + operations = { + "uppercase": data.upper(), + "lowercase": data.lower(), + "reverse": data[::-1], + "length": str(len(data)) + } + + result = operations.get(operation) + if result is None: + raise ValueError(f"Unknown operation: {operation}") + + return result + +@mcp.resource("api://status") +def get_api_status() -> str: + """Get API server status.""" + return "API server is running and healthy" + +# Regular FastAPI endpoints +@app.get("/") +async def root(): + return {"message": "FastAPI with MCP integration", "mcp_endpoint": "/mcp"} + +@app.get("/health") +async def health(): + return {"status": "healthy", "mcp_available": True} + +@app.post("/api/process") +async def api_process(data: dict): + """Regular API endpoint that could leverage MCP tools.""" + if "text" not in data: + raise HTTPException(status_code=400, detail="Missing 'text' field") + + # In a real app, you might call MCP tools internally + text = data["text"] + operation = data.get("operation", "uppercase") + + # Simulate calling the MCP tool + result = process_api_data(text, operation) + + return {"processed": result, "operation": operation} + +# Mount MCP server +app.mount("/mcp", mcp.streamable_http_app()) + +# Lifecycle management +@app.on_event("startup") +async def startup(): + await mcp.session_manager.start() + +@app.on_event("shutdown") +async def shutdown(): + await mcp.session_manager.stop() + +# Run with: uvicorn app:app --host 0.0.0.0 --port 8000 +``` + +### Shared services integration + +```python +""" +FastAPI and MCP sharing database and services. +""" + +from contextlib import asynccontextmanager +from collections.abc import AsyncIterator +from dataclasses import dataclass +from fastapi import FastAPI, Depends +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession +import asyncpg + +@dataclass +class SharedServices: + """Shared services between FastAPI and MCP.""" + db_pool: asyncpg.Pool + cache: dict = None + + def __post_init__(self): + if self.cache is None: + self.cache = {} + +# Global services +services: SharedServices | None = None + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + """Shared lifespan for both FastAPI and MCP.""" + global services + + # Initialize shared services + db_pool = await asyncpg.create_pool( + "postgresql://user:pass@localhost/db", + min_size=5, + max_size=20 + ) + + services = SharedServices(db_pool=db_pool) + + # Start MCP server + await mcp.session_manager.start() + + try: + yield + finally: + # Cleanup + await mcp.session_manager.stop() + await db_pool.close() + +# Create FastAPI app with lifespan +app = FastAPI(lifespan=lifespan) + +# MCP server with access to shared services +mcp = FastMCP("Shared Services MCP") + +@mcp.tool() +async def query_database( + sql: str, + ctx: Context[ServerSession, None] +) -> list[dict]: + """Execute database query using shared connection pool.""" + if not services: + raise RuntimeError("Services not initialized") + + await ctx.info(f"Executing query: {sql}") + + async with services.db_pool.acquire() as conn: + rows = await conn.fetch(sql) + results = [dict(row) for row in rows] + + await ctx.info(f"Query returned {len(results)} rows") + return results + +@mcp.tool() +async def cache_operation( + key: str, + value: str | None = None, + ctx: Context[ServerSession, None] +) -> dict: + """Cache operations using shared cache.""" + if not services: + raise RuntimeError("Services not initialized") + + if value is not None: + # Set value + services.cache[key] = value + await ctx.info(f"Cached {key} = {value}") + return {"action": "set", "key": key, "value": value} + else: + # Get value + cached_value = services.cache.get(key) + await ctx.debug(f"Retrieved {key} = {cached_value}") + return {"action": "get", "key": key, "value": cached_value} + +# FastAPI endpoints using shared services +def get_services() -> SharedServices: + """Dependency to get shared services.""" + if not services: + raise RuntimeError("Services not initialized") + return services + +@app.get("/api/users") +async def list_users(services: SharedServices = Depends(get_services)): + """List users using shared database.""" + async with services.db_pool.acquire() as conn: + rows = await conn.fetch("SELECT id, name FROM users LIMIT 10") + return [{"id": row["id"], "name": row["name"]} for row in rows] + +@app.get("/api/cache/{key}") +async def get_cache(key: str, services: SharedServices = Depends(get_services)): + """Get cached value.""" + return {"key": key, "value": services.cache.get(key)} + +# Mount MCP +app.mount("/mcp", mcp.streamable_http_app()) +``` + +## Starlette integration + +### Multiple MCP servers + +```python +""" +Starlette app with multiple specialized MCP servers. +""" + +import contextlib +from starlette.applications import Starlette +from starlette.routing import Mount, Route +from starlette.responses import JSONResponse +from mcp.server.fastmcp import FastMCP + +# Create specialized MCP servers +user_mcp = FastMCP("User Management", stateless_http=True) +analytics_mcp = FastMCP("Analytics", stateless_http=True) +admin_mcp = FastMCP("Admin Tools", stateless_http=True) + +# User management tools +@user_mcp.tool() +def create_user(username: str, email: str) -> dict: + """Create a new user.""" + user_id = f"user_{hash(username) % 10000:04d}" + return { + "user_id": user_id, + "username": username, + "email": email, + "status": "created" + } + +@user_mcp.resource("user://{user_id}") +def get_user_profile(user_id: str) -> str: + """Get user profile information.""" + return f"""User Profile: {user_id} +Name: Example User +Email: user@example.com +Status: Active +Created: 2024-01-01""" + +# Analytics tools +@analytics_mcp.tool() +def calculate_metrics(data: list[float]) -> dict: + """Calculate analytics metrics.""" + if not data: + return {"error": "No data provided"} + + return { + "count": len(data), + "sum": sum(data), + "mean": sum(data) / len(data), + "min": min(data), + "max": max(data) + } + +@analytics_mcp.resource("metrics://daily") +def get_daily_metrics() -> str: + """Get daily metrics summary.""" + return """Daily Metrics Summary: +- Users: 1,234 active +- Requests: 45,678 total +- Errors: 12 (0.03%) +- Response time: 145ms avg""" + +# Admin tools +@admin_mcp.tool() +def system_status() -> dict: + """Get system status information.""" + return { + "status": "healthy", + "uptime": "5 days, 12 hours", + "memory_usage": "45%", + "cpu_usage": "23%", + "disk_usage": "67%" + } + +# Regular Starlette routes +async def homepage(request): + return JSONResponse({ + "message": "Multi-MCP Starlette Application", + "mcp_services": { + "users": "/users/mcp", + "analytics": "/analytics/mcp", + "admin": "/admin/mcp" + } + }) + +async def health_check(request): + return JSONResponse({"status": "healthy", "services": 3}) + +# Combined lifespan manager +@contextlib.asynccontextmanager +async def lifespan(app): + async with contextlib.AsyncExitStack() as stack: + await stack.enter_async_context(user_mcp.session_manager.run()) + await stack.enter_async_context(analytics_mcp.session_manager.run()) + await stack.enter_async_context(admin_mcp.session_manager.run()) + yield + +# Create Starlette application +app = Starlette( + routes=[ + Route("/", homepage), + Route("/health", health_check), + Mount("/users", user_mcp.streamable_http_app()), + Mount("/analytics", analytics_mcp.streamable_http_app()), + Mount("/admin", admin_mcp.streamable_http_app()), + ], + lifespan=lifespan +) + +# Run with: uvicorn app:app --host 0.0.0.0 --port 8000 +``` + +## Django integration + +### Django ASGI application + +```python +""" +Django ASGI integration with MCP server. + +Add to Django project's asgi.py file. +""" + +import os +from django.core.asgi import get_asgi_application +from starlette.applications import Starlette +from starlette.routing import Mount +from mcp.server.fastmcp import FastMCP + +# Configure Django +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings') + +# Get Django ASGI application +django_asgi_app = get_asgi_application() + +# Create MCP server +mcp = FastMCP("Django MCP Integration") + +@mcp.tool() +def django_model_stats() -> dict: + """Get Django model statistics.""" + # Import Django models + from django.contrib.auth.models import User + from myapp.models import MyModel # Your app models + + return { + "users_count": User.objects.count(), + "mymodel_count": MyModel.objects.count(), + "recent_users": User.objects.filter( + date_joined__gte=timezone.now() - timedelta(days=7) + ).count() + } + +@mcp.resource("django://models/{model_name}") +def get_model_info(model_name: str) -> str: + """Get information about Django models.""" + from django.apps import apps + + try: + model = apps.get_model(model_name) + field_info = [] + for field in model._meta.fields: + field_info.append(f"- {field.name}: {field.__class__.__name__}") + + return f"""Model: {model_name} +Fields: +{chr(10).join(field_info)} +Table: {model._meta.db_table}""" + + except LookupError: + return f"Model '{model_name}' not found" + +# Combined ASGI application +async def startup(): + await mcp.session_manager.start() + +async def shutdown(): + await mcp.session_manager.stop() + +# Create combined application +from starlette.applications import Starlette + +combined_app = Starlette() +combined_app.add_event_handler("startup", startup) +combined_app.add_event_handler("shutdown", shutdown) + +combined_app.mount("/mcp", mcp.streamable_http_app()) +combined_app.mount("/", django_asgi_app) + +application = combined_app +``` + +### Django management command + +```python +""" +Django management command to run MCP server. + +Save as: myapp/management/commands/run_mcp.py +""" + +from django.core.management.base import BaseCommand +from django.conf import settings +from mcp.server.fastmcp import FastMCP + +class Command(BaseCommand): + help = 'Run MCP server for Django integration' + + def add_arguments(self, parser): + parser.add_argument('--host', default='localhost', help='Host to bind to') + parser.add_argument('--port', type=int, default=8001, help='Port to bind to') + parser.add_argument('--debug', action='store_true', help='Enable debug mode') + + def handle(self, *args, **options): + from myapp.mcp_server import create_mcp_server + + mcp = create_mcp_server(debug=options['debug']) + + self.stdout.write( + self.style.SUCCESS( + f"Starting MCP server on {options['host']}:{options['port']}" + ) + ) + + mcp.run( + transport="streamable-http", + host=options['host'], + port=options['port'] + ) + +# Usage: python manage.py run_mcp --host 0.0.0.0 --port 8001 +``` + +## Middleware integration + +### Shared authentication middleware + +```python +""" +Shared authentication between FastAPI and MCP. +""" + +from fastapi import FastAPI, HTTPException, Depends +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response +from mcp.server.fastmcp import FastMCP +import jwt + +# Shared authentication logic +class AuthService: + SECRET_KEY = "your-secret-key" + + @classmethod + def verify_token(cls, token: str) -> dict | None: + try: + payload = jwt.decode(token, cls.SECRET_KEY, algorithms=["HS256"]) + return payload + except jwt.InvalidTokenError: + return None + + @classmethod + def create_token(cls, user_id: str) -> str: + return jwt.encode({"user_id": user_id}, cls.SECRET_KEY, algorithm="HS256") + +# FastAPI security +security = HTTPBearer() + +async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)): + token = credentials.credentials + payload = AuthService.verify_token(token) + if not payload: + raise HTTPException(status_code=401, detail="Invalid token") + return payload + +# Shared middleware +class AuthMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + # Skip auth for certain paths + if request.url.path in ["/health", "/login"]: + return await call_next(request) + + # Check authorization header + auth_header = request.headers.get("authorization") + if not auth_header or not auth_header.startswith("Bearer "): + return Response("Unauthorized", status_code=401) + + token = auth_header.split(" ")[1] + user = AuthService.verify_token(token) + if not user: + return Response("Invalid token", status_code=401) + + # Add user to request state + request.state.user = user + return await call_next(request) + +# FastAPI app with auth +app = FastAPI() +app.add_middleware(AuthMiddleware) + +@app.post("/login") +async def login(credentials: dict): + # Simple login (use proper authentication in production) + if credentials.get("username") == "admin" and credentials.get("password") == "secret": + token = AuthService.create_token("admin") + return {"access_token": token, "token_type": "bearer"} + raise HTTPException(status_code=401, detail="Invalid credentials") + +@app.get("/protected") +async def protected_endpoint(user: dict = Depends(get_current_user)): + return {"message": f"Hello {user['user_id']}", "protected": True} + +# MCP server (will inherit auth middleware when mounted) +mcp = FastMCP("Authenticated MCP") + +@mcp.tool() +def authenticated_tool(data: str) -> str: + """Tool that requires authentication.""" + # Authentication is handled by middleware + return f"Processed: {data}" + +# Mount MCP with auth middleware +app.mount("/mcp", mcp.streamable_http_app()) + +@app.on_event("startup") +async def startup(): + await mcp.session_manager.start() + +@app.on_event("shutdown") +async def shutdown(): + await mcp.session_manager.stop() +``` + +## Load balancing and scaling + +### Multi-instance deployment + +```python +""" +Load-balanced MCP deployment with shared state. +""" + +import redis.asyncio as redis +from fastapi import FastAPI +from mcp.server.fastmcp import FastMCP +import os +import json + +# Instance identification +INSTANCE_ID = os.getenv("INSTANCE_ID", "instance-1") +REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379") + +app = FastAPI(title=f"MCP Instance {INSTANCE_ID}") + +# Shared state via Redis +redis_client = redis.from_url(REDIS_URL) + +mcp = FastMCP(f"MCP {INSTANCE_ID}", stateless_http=True) + +@mcp.tool() +async def distributed_counter(operation: str = "increment") -> dict: + """Distributed counter across instances.""" + key = "distributed_counter" + + if operation == "increment": + new_value = await redis_client.incr(key) + return { + "operation": "increment", + "value": new_value, + "instance": INSTANCE_ID + } + elif operation == "get": + value = await redis_client.get(key) + return { + "operation": "get", + "value": int(value) if value else 0, + "instance": INSTANCE_ID + } + elif operation == "reset": + await redis_client.delete(key) + return { + "operation": "reset", + "value": 0, + "instance": INSTANCE_ID + } + else: + raise ValueError(f"Unknown operation: {operation}") + +@mcp.tool() +async def instance_info() -> dict: + """Get information about this instance.""" + return { + "instance_id": INSTANCE_ID, + "redis_connected": await redis_client.ping(), + "status": "healthy" + } + +@mcp.resource("cluster://status") +async def cluster_status() -> str: + """Get cluster status information.""" + # Store instance heartbeat + await redis_client.setex(f"instance:{INSTANCE_ID}", 60, "alive") + + # Get all active instances + keys = await redis_client.keys("instance:*") + active_instances = [key.decode().split(":")[1] for key in keys] + + return f"""Cluster Status: +Active Instances: {len(active_instances)} +Instance List: {', '.join(active_instances)} +Current Instance: {INSTANCE_ID} +Redis Connected: True""" + +# Health check endpoint +@app.get("/health") +async def health(): + return { + "instance": INSTANCE_ID, + "status": "healthy", + "redis": await redis_client.ping() + } + +# Mount MCP +app.mount("/mcp", mcp.streamable_http_app()) + +@app.on_event("startup") +async def startup(): + await mcp.session_manager.start() + # Register instance + await redis_client.setex(f"instance:{INSTANCE_ID}", 60, "alive") + +@app.on_event("shutdown") +async def shutdown(): + await mcp.session_manager.stop() + # Unregister instance + await redis_client.delete(f"instance:{INSTANCE_ID}") + await redis_client.close() +``` + +## Testing ASGI integration + +### Integration tests + +```python +""" +Integration tests for ASGI-mounted MCP servers. +""" + +import pytest +import asyncio +from httpx import AsyncClient +from fastapi import FastAPI +from mcp.server.fastmcp import FastMCP + +@pytest.fixture +async def test_app(): + """Create test FastAPI app with MCP integration.""" + app = FastAPI() + mcp = FastMCP("Test MCP") + + @mcp.tool() + def test_tool(value: str) -> str: + return f"Test: {value}" + + @app.get("/api/test") + async def api_test(): + return {"message": "API working"} + + app.mount("/mcp", mcp.streamable_http_app()) + + @app.on_event("startup") + async def startup(): + await mcp.session_manager.start() + + @app.on_event("shutdown") + async def shutdown(): + await mcp.session_manager.stop() + + return app + +@pytest.mark.asyncio +async def test_api_and_mcp_integration(test_app): + """Test both API and MCP endpoints work.""" + async with AsyncClient(app=test_app, base_url="http://test") as client: + # Test regular API endpoint + api_response = await client.get("/api/test") + assert api_response.status_code == 200 + assert api_response.json()["message"] == "API working" + + # Test MCP endpoint + mcp_request = { + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "clientInfo": {"name": "Test", "version": "1.0.0"}, + "capabilities": {} + } + } + + mcp_response = await client.post("/mcp", json=mcp_request) + assert mcp_response.status_code == 200 + + # Test MCP tool call + tool_request = { + "method": "tools/call", + "params": { + "name": "test_tool", + "arguments": {"value": "hello"} + } + } + + tool_response = await client.post("/mcp", json=tool_request) + assert tool_response.status_code == 200 +``` + +## Best practices + +### Design guidelines + +- **Separate concerns** - Keep web API and MCP functionality distinct +- **Share resources wisely** - Database pools, caches, but not request state +- **Use stateless MCP** - Better for scaling with web applications +- **Consistent authentication** - Use same auth system for both interfaces +- **Health checks** - Monitor both web and MCP endpoints + +### Performance considerations + +- **Connection pooling** - Share database and Redis connections +- **Async operations** - Use async/await throughout +- **Resource limits** - Set appropriate timeouts and limits +- **Monitoring** - Track both web and MCP metrics +- **Load balancing** - Distribute load across instances + +### Security best practices + +- **Unified authentication** - Same security model for both interfaces +- **Input validation** - Validate data at both API and MCP layers +- **Rate limiting** - Apply limits to both endpoint types +- **HTTPS only** - Use TLS for all production traffic +- **Audit logging** - Log access to both interfaces + +## Next steps + +- **[Running servers](running-servers.md)** - Production deployment strategies +- **[Streamable HTTP](streamable-http.md)** - Advanced HTTP transport configuration +- **[Authentication](authentication.md)** - Secure your integrated applications +- **[Writing clients](writing-clients.md)** - Build clients for integrated services \ No newline at end of file diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 000000000..0da951d79 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,596 @@ +# Authentication + +The MCP Python SDK implements OAuth 2.1 resource server functionality, allowing servers to validate tokens and protect resources. This follows the MCP authorization specification and RFC 9728. + +## OAuth 2.1 architecture + +MCP uses a three-party OAuth model: + +- **Authorization Server (AS)** - Handles user authentication and token issuance +- **Resource Server (RS)** - Your MCP server that validates tokens +- **Client** - Applications that access protected MCP resources + +## Basic authentication setup + +### Creating an authenticated server + +```python +from pydantic import AnyHttpUrl +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.server.auth.settings import AuthSettings +from mcp.server.fastmcp import FastMCP + +class SimpleTokenVerifier(TokenVerifier): + """Simple token verifier implementation.""" + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify and decode an access token.""" + # In production, validate JWT signatures, check expiration, etc. + if token.startswith("valid_"): + return AccessToken( + subject="user123", + scopes=["read", "write"], + expires_at=None, # Non-expiring for demo + client_id="demo_client" + ) + return None # Invalid token + +# Create server with authentication +mcp = FastMCP( + "Protected Weather Service", + token_verifier=SimpleTokenVerifier(), + auth=AuthSettings( + issuer_url=AnyHttpUrl("https://auth.example.com"), + resource_server_url=AnyHttpUrl("http://localhost:3001"), + required_scopes=["weather:read"] + ) +) + +@mcp.tool() +async def get_weather(city: str = "London") -> dict[str, str]: + """Get weather data - requires authentication.""" + return { + "city": city, + "temperature": "22°C", + "condition": "Sunny", + "humidity": "45%" + } +``` + +### Advanced token verification + +```python +import jwt +import time +from typing import Optional + +class JWTTokenVerifier(TokenVerifier): + """JWT-based token verifier.""" + + def __init__(self, public_key: str, algorithm: str = "RS256"): + self.public_key = public_key + self.algorithm = algorithm + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify JWT token.""" + try: + # Decode and verify JWT + payload = jwt.decode( + token, + self.public_key, + algorithms=[self.algorithm], + options={"verify_exp": True} + ) + + # Extract standard claims + subject = payload.get("sub") + scopes = payload.get("scope", "").split() + expires_at = payload.get("exp") + client_id = payload.get("client_id") + + if not subject: + return None + + return AccessToken( + subject=subject, + scopes=scopes, + expires_at=expires_at, + client_id=client_id, + raw_token=token + ) + + except jwt.InvalidTokenError: + return None + except Exception: + # Log error in production + return None + +# Use JWT verifier +jwt_verifier = JWTTokenVerifier(public_key="your-rsa-public-key") +mcp = FastMCP("JWT Protected Service", token_verifier=jwt_verifier) +``` + +## Scope-based authorization + +### Protecting resources by scope + +```python +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + +mcp = FastMCP("Scoped API", token_verifier=SimpleTokenVerifier()) + +@mcp.tool() +async def read_data(ctx: Context[ServerSession, None]) -> dict: + """Read data - requires 'read' scope.""" + # Access token information from context + if hasattr(ctx.session, 'access_token'): + token = ctx.session.access_token + if "read" not in token.scopes: + raise ValueError("Insufficient permissions: read scope required") + + await ctx.info(f"Data accessed by user: {token.subject}") + return {"data": "sensitive information", "user": token.subject} + + raise ValueError("Authentication required") + +@mcp.tool() +async def write_data(data: str, ctx: Context[ServerSession, None]) -> dict: + """Write data - requires 'write' scope.""" + if hasattr(ctx.session, 'access_token'): + token = ctx.session.access_token + if "write" not in token.scopes: + raise ValueError("Insufficient permissions: write scope required") + + await ctx.info(f"Data written by user: {token.subject}") + return {"status": "written", "data": data, "user": token.subject} + + raise ValueError("Authentication required") + +@mcp.tool() +async def admin_operation(ctx: Context[ServerSession, None]) -> dict: + """Admin operation - requires 'admin' scope.""" + if hasattr(ctx.session, 'access_token'): + token = ctx.session.access_token + if "admin" not in token.scopes: + raise ValueError("Admin access required") + + return {"message": "Admin operation completed", "admin": token.subject} + + raise ValueError("Authentication required") +``` + +### Custom authorization decorators + +```python +from functools import wraps + +def require_scopes(*required_scopes): + """Decorator to require specific scopes.""" + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + # Find context argument + ctx = None + for arg in args: + if isinstance(arg, Context): + ctx = arg + break + + if not ctx: + raise ValueError("Context required for authorization") + + if not hasattr(ctx.session, 'access_token'): + raise ValueError("Authentication required") + + token = ctx.session.access_token + missing_scopes = set(required_scopes) - set(token.scopes) + + if missing_scopes: + raise ValueError(f"Missing required scopes: {', '.join(missing_scopes)}") + + return await func(*args, **kwargs) + + return wrapper + return decorator + +@mcp.tool() +@require_scopes("user:profile", "user:email") +async def get_user_profile(user_id: str, ctx: Context) -> dict: + """Get user profile - requires specific scopes.""" + token = ctx.session.access_token + await ctx.info(f"Profile accessed by {token.subject} for user {user_id}") + + return { + "user_id": user_id, + "name": "John Doe", + "email": "john@example.com", + "accessed_by": token.subject + } +``` + +## Token introspection + +### OAuth token introspection + +```python +import aiohttp +import json + +class IntrospectionTokenVerifier(TokenVerifier): + """Token verifier using OAuth introspection endpoint.""" + + def __init__(self, introspection_url: str, client_id: str, client_secret: str): + self.introspection_url = introspection_url + self.client_id = client_id + self.client_secret = client_secret + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify token using introspection endpoint.""" + try: + async with aiohttp.ClientSession() as session: + # Prepare introspection request + data = { + "token": token, + "token_type_hint": "access_token" + } + + auth = aiohttp.BasicAuth(self.client_id, self.client_secret) + + async with session.post( + self.introspection_url, + data=data, + auth=auth + ) as response: + if response.status != 200: + return None + + result = await response.json() + + # Check if token is active + if not result.get("active", False): + return None + + # Extract token information + return AccessToken( + subject=result.get("sub"), + scopes=result.get("scope", "").split(), + expires_at=result.get("exp"), + client_id=result.get("client_id"), + raw_token=token + ) + + except Exception: + # Log error in production + return None + +# Use introspection verifier +introspection_verifier = IntrospectionTokenVerifier( + introspection_url="https://auth.example.com/oauth/introspect", + client_id="mcp_server", + client_secret="server_secret" +) + +mcp = FastMCP("Introspection Server", token_verifier=introspection_verifier) +``` + +## Database-backed authorization + +### User and permission management + +```python +from dataclasses import dataclass +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +@dataclass +class User: + id: str + username: str + roles: list[str] + permissions: list[str] + +class AuthDatabase: + """Mock authentication database.""" + + def __init__(self): + self.users = { + "user123": User("user123", "alice", ["user"], ["read", "write"]), + "admin456": User("admin456", "admin", ["admin"], ["read", "write", "delete", "admin"]) + } + + async def get_user(self, user_id: str) -> User | None: + return self.users.get(user_id) + + async def verify_token(self, token: str) -> User | None: + # Simple token format: "token_userid" + if token.startswith("token_"): + user_id = token[6:] # Remove "token_" prefix + return await self.get_user(user_id) + return None + +@dataclass +class AppContext: + auth_db: AuthDatabase + +@asynccontextmanager +async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: + auth_db = AuthDatabase() + yield AppContext(auth_db=auth_db) + +class DatabaseTokenVerifier(TokenVerifier): + """Token verifier using database lookup.""" + + def __init__(self, auth_db: AuthDatabase): + self.auth_db = auth_db + + async def verify_token(self, token: str) -> AccessToken | None: + user = await self.auth_db.verify_token(token) + if user: + return AccessToken( + subject=user.id, + scopes=user.permissions, + expires_at=None, + client_id="database_client" + ) + return None + +# Create server with database authentication +auth_db = AuthDatabase() +mcp = FastMCP( + "Database Auth Server", + lifespan=app_lifespan, + token_verifier=DatabaseTokenVerifier(auth_db) +) + +@mcp.tool() +async def get_user_info( + user_id: str, + ctx: Context[ServerSession, AppContext] +) -> dict: + """Get user information - requires authentication.""" + # Verify user is authenticated + if not hasattr(ctx.session, 'access_token'): + raise ValueError("Authentication required") + + token = ctx.session.access_token + auth_db = ctx.request_context.lifespan_context.auth_db + + # Check if user can access this information + if token.subject != user_id and "admin" not in token.scopes: + raise ValueError("Insufficient permissions") + + user = await auth_db.get_user(user_id) + if not user: + raise ValueError("User not found") + + return { + "user_id": user.id, + "username": user.username, + "roles": user.roles, + "permissions": user.permissions + } +``` + +## Error handling and security + +### Authentication error handling + +```python +@mcp.tool() +async def secure_operation(data: str, ctx: Context) -> dict: + """Secure operation with comprehensive error handling.""" + try: + # Check authentication + if not hasattr(ctx.session, 'access_token'): + await ctx.warning("Unauthenticated access attempt") + raise ValueError("Authentication required") + + token = ctx.session.access_token + + # Check token expiration + if token.expires_at and token.expires_at < time.time(): + await ctx.warning(f"Expired token used by {token.subject}") + raise ValueError("Token expired") + + # Check required scopes + required_scopes = ["secure:access"] + missing_scopes = set(required_scopes) - set(token.scopes) + if missing_scopes: + await ctx.warning(f"Insufficient scopes for {token.subject}: missing {missing_scopes}") + raise ValueError(f"Missing required scopes: {', '.join(missing_scopes)}") + + # Log successful access + await ctx.info(f"Secure operation accessed by {token.subject}") + + # Perform secure operation + return { + "result": f"Processed: {data}", + "user": token.subject, + "timestamp": time.time() + } + + except ValueError as e: + await ctx.error(f"Authorization failed: {e}") + raise + except Exception as e: + await ctx.error(f"Unexpected error in secure operation: {e}") + raise ValueError("Internal server error") +``` + +### Rate limiting by user + +```python +import time +from collections import defaultdict + +class RateLimiter: + """Simple rate limiter by user.""" + + def __init__(self, requests_per_minute: int = 60): + self.requests_per_minute = requests_per_minute + self.requests = defaultdict(list) + + def is_allowed(self, user_id: str) -> bool: + """Check if user is within rate limits.""" + now = time.time() + minute_ago = now - 60 + + # Clean old requests + self.requests[user_id] = [ + req_time for req_time in self.requests[user_id] + if req_time > minute_ago + ] + + # Check if under limit + if len(self.requests[user_id]) >= self.requests_per_minute: + return False + + # Record this request + self.requests[user_id].append(now) + return True + +# Global rate limiter +rate_limiter = RateLimiter(requests_per_minute=100) + +def rate_limited(func): + """Decorator to apply rate limiting.""" + @wraps(func) + async def wrapper(*args, **kwargs): + # Find context + ctx = None + for arg in args: + if isinstance(arg, Context): + ctx = arg + break + + if ctx and hasattr(ctx.session, 'access_token'): + user_id = ctx.session.access_token.subject + + if not rate_limiter.is_allowed(user_id): + await ctx.warning(f"Rate limit exceeded for user {user_id}") + raise ValueError("Rate limit exceeded") + + return await func(*args, **kwargs) + + return wrapper + +@mcp.tool() +@rate_limited +async def api_call(endpoint: str, ctx: Context) -> dict: + """Rate-limited API call.""" + token = ctx.session.access_token + await ctx.info(f"API call to {endpoint} by {token.subject}") + + return { + "endpoint": endpoint, + "user": token.subject, + "result": "API response data" + } +``` + +## Testing authentication + +### Unit testing with mock tokens + +```python +import pytest +from unittest.mock import Mock, AsyncMock + +@pytest.mark.asyncio +async def test_authenticated_tool(): + """Test tool with authentication.""" + # Create mock context with token + mock_ctx = Mock() + mock_ctx.session = Mock() + mock_ctx.session.access_token = AccessToken( + subject="test_user", + scopes=["read", "write"], + expires_at=None, + client_id="test_client" + ) + mock_ctx.info = AsyncMock() + + # Test authenticated function + @require_scopes("read") + async def test_function(data: str, ctx: Context) -> dict: + await ctx.info("Function called") + return {"data": data, "user": ctx.session.access_token.subject} + + result = await test_function("test", mock_ctx) + + assert result["data"] == "test" + assert result["user"] == "test_user" + mock_ctx.info.assert_called_once() + +@pytest.mark.asyncio +async def test_insufficient_scopes(): + """Test scope enforcement.""" + mock_ctx = Mock() + mock_ctx.session = Mock() + mock_ctx.session.access_token = AccessToken( + subject="test_user", + scopes=["read"], # Missing 'write' scope + expires_at=None, + client_id="test_client" + ) + + @require_scopes("read", "write") + async def test_function(ctx: Context) -> dict: + return {"result": "success"} + + with pytest.raises(ValueError, match="Missing required scopes"): + await test_function(mock_ctx) +``` + +## Production considerations + +### Security best practices + +- **Validate all tokens** - Never trust client-provided tokens +- **Use HTTPS only** - All authentication must happen over secure connections +- **Implement proper logging** - Log authentication events for security monitoring +- **Rate limiting** - Prevent abuse with per-user rate limits +- **Token expiration** - Use short-lived tokens with refresh capabilities +- **Scope minimization** - Grant minimum required permissions + +### Performance optimization + +- **Token caching** - Cache validated tokens to reduce verification overhead +- **Connection pooling** - Reuse HTTP connections for introspection +- **Database optimization** - Index user/permission lookup tables +- **Async operations** - Use async/await for all I/O operations + +### Monitoring and alerting + +```python +import logging + +# Configure security logger +security_logger = logging.getLogger("security") + +@mcp.tool() +async def monitored_operation(ctx: Context) -> dict: + """Operation with security monitoring.""" + if not hasattr(ctx.session, 'access_token'): + security_logger.warning("Unauthenticated access attempt") + raise ValueError("Authentication required") + + token = ctx.session.access_token + + # Log successful access + security_logger.info(f"Secure access by {token.subject} with scopes {token.scopes}") + + # Check for suspicious patterns + if "admin" in token.scopes and token.subject != "admin_user": + security_logger.warning(f"Non-admin user {token.subject} has admin scopes") + + return {"status": "success", "user": token.subject} +``` + +## Next steps + +- **[Server deployment](running-servers.md)** - Deploy authenticated servers +- **[Client authentication](oauth-clients.md)** - Implement client-side OAuth +- **[Advanced security](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization)** - Full MCP authorization spec +- **[Monitoring](progress-logging.md)** - Security logging and monitoring \ No newline at end of file diff --git a/docs/completions.md b/docs/completions.md new file mode 100644 index 000000000..203f0e20a --- /dev/null +++ b/docs/completions.md @@ -0,0 +1,1053 @@ +# Completions + +Learn how to integrate LLM text generation and completions into your MCP servers for advanced AI-powered functionality. + +## Overview + +MCP completions enable: + +- **LLM integration** - Generate text using language models +- **Smart automation** - AI-powered content generation and analysis +- **Interactive workflows** - Dynamic responses based on user input +- **Content enhancement** - Improve and expand existing content +- **Decision support** - AI-assisted decision making + +## Basic completions + +### Simple text completion + +```python +""" +Basic LLM completions in MCP servers. +""" + +from mcp.server.fastmcp import FastMCP +from mcp.types import SamplingMessage, Role +import asyncio +import os + +# Create server +mcp = FastMCP("AI Completion Server") + +@mcp.tool() +async def complete_text( + prompt: str, + max_tokens: int = 100, + temperature: float = 0.7 +) -> str: + """Complete text using LLM.""" + + # Create sampling message + message = SamplingMessage( + role=Role.USER, + content={"type": "text", "text": prompt} + ) + + # Request completion from client + try: + completion = await mcp.request_sampling( + messages=[message], + max_tokens=max_tokens, + temperature=temperature + ) + + if completion and completion.content: + content = completion.content[0] + if hasattr(content, 'text'): + return content.text + + return "No completion generated" + + except Exception as e: + return f"Error generating completion: {e}" + +@mcp.tool() +async def summarize_text( + text: str, + summary_length: str = "medium" +) -> str: + """Summarize text using LLM.""" + + length_instructions = { + "short": "in 1-2 sentences", + "medium": "in 3-5 sentences", + "long": "in 1-2 paragraphs" + } + + instruction = length_instructions.get(summary_length, "in 3-5 sentences") + + prompt = f"""Please summarize the following text {instruction}: + +{text} + +Summary:""" + + message = SamplingMessage( + role=Role.USER, + content={"type": "text", "text": prompt} + ) + + try: + completion = await mcp.request_sampling( + messages=[message], + max_tokens=200, + temperature=0.3 # Lower temperature for factual summaries + ) + + if completion and completion.content: + content = completion.content[0] + if hasattr(content, 'text'): + return content.text.strip() + + return "Could not generate summary" + + except Exception as e: + return f"Error generating summary: {e}" + +@mcp.tool() +async def analyze_sentiment(text: str) -> dict: + """Analyze sentiment of text using LLM.""" + + prompt = f"""Analyze the sentiment of the following text and provide: +1. Overall sentiment (positive, negative, or neutral) +2. Confidence score (0-1) +3. Key emotional indicators +4. Brief explanation + +Text: "{text}" + +Please respond in JSON format:""" + + message = SamplingMessage( + role=Role.USER, + content={"type": "text", "text": prompt} + ) + + try: + completion = await mcp.request_sampling( + messages=[message], + max_tokens=150, + temperature=0.2 + ) + + if completion and completion.content: + content = completion.content[0] + if hasattr(content, 'text'): + import json + try: + return json.loads(content.text) + except json.JSONDecodeError: + return {"error": "Could not parse response as JSON", "raw_response": content.text} + + return {"error": "No response generated"} + + except Exception as e: + return {"error": f"Error analyzing sentiment: {e}"} + +if __name__ == "__main__": + mcp.run() +``` + +## Conversational completions + +### Multi-turn conversations + +```python +""" +Multi-turn conversation handling with completions. +""" + +from typing import List, Dict, Any, Optional +from dataclasses import dataclass, field +from datetime import datetime +import uuid + +@dataclass +class ConversationTurn: + """Represents a single conversation turn.""" + id: str + role: Role + content: str + timestamp: datetime = field(default_factory=datetime.now) + metadata: Dict[str, Any] = field(default_factory=dict) + +@dataclass +class Conversation: + """Represents a conversation with multiple turns.""" + id: str + turns: List[ConversationTurn] = field(default_factory=list) + created_at: datetime = field(default_factory=datetime.now) + updated_at: datetime = field(default_factory=datetime.now) + metadata: Dict[str, Any] = field(default_factory=dict) + + def add_turn(self, role: Role, content: str, metadata: Dict[str, Any] = None): + """Add a new turn to the conversation.""" + turn = ConversationTurn( + id=str(uuid.uuid4()), + role=role, + content=content, + metadata=metadata or {} + ) + self.turns.append(turn) + self.updated_at = datetime.now() + return turn + + def get_messages(self) -> List[SamplingMessage]: + """Convert conversation turns to sampling messages.""" + messages = [] + for turn in self.turns: + message = SamplingMessage( + role=turn.role, + content={"type": "text", "text": turn.content} + ) + messages.append(message) + return messages + +class ConversationManager: + """Manages multiple conversations.""" + + def __init__(self): + self.conversations: Dict[str, Conversation] = {} + + def create_conversation(self, initial_message: str = None, metadata: Dict[str, Any] = None) -> str: + """Create a new conversation.""" + conversation_id = str(uuid.uuid4()) + conversation = Conversation( + id=conversation_id, + metadata=metadata or {} + ) + + if initial_message: + conversation.add_turn(Role.USER, initial_message) + + self.conversations[conversation_id] = conversation + return conversation_id + + def add_message(self, conversation_id: str, role: Role, content: str, metadata: Dict[str, Any] = None) -> bool: + """Add a message to an existing conversation.""" + if conversation_id not in self.conversations: + return False + + self.conversations[conversation_id].add_turn(role, content, metadata) + return True + + def get_conversation(self, conversation_id: str) -> Optional[Conversation]: + """Get a conversation by ID.""" + return self.conversations.get(conversation_id) + + def list_conversations(self) -> List[Dict[str, Any]]: + """List all conversations with metadata.""" + return [ + { + "id": conv.id, + "turn_count": len(conv.turns), + "created_at": conv.created_at.isoformat(), + "updated_at": conv.updated_at.isoformat(), + "metadata": conv.metadata + } + for conv in self.conversations.values() + ] + +# Global conversation manager +conversation_manager = ConversationManager() + +@mcp.tool() +def start_conversation(initial_message: str = "", context: str = "") -> dict: + """Start a new conversation.""" + metadata = {"context": context} if context else {} + + conversation_id = conversation_manager.create_conversation( + initial_message=initial_message if initial_message else None, + metadata=metadata + ) + + return { + "conversation_id": conversation_id, + "message": "Conversation started", + "initial_message": initial_message + } + +@mcp.tool() +async def chat(conversation_id: str, message: str, temperature: float = 0.7) -> dict: + """Continue a conversation with a new message.""" + conversation = conversation_manager.get_conversation(conversation_id) + if not conversation: + return {"error": f"Conversation {conversation_id} not found"} + + # Add user message + conversation.add_turn(Role.USER, message) + + # Get conversation history + messages = conversation.get_messages() + + try: + # Request completion with full conversation context + completion = await mcp.request_sampling( + messages=messages, + max_tokens=300, + temperature=temperature + ) + + if completion and completion.content: + content = completion.content[0] + if hasattr(content, 'text'): + response_text = content.text.strip() + + # Add assistant response to conversation + conversation.add_turn(Role.ASSISTANT, response_text) + + return { + "conversation_id": conversation_id, + "response": response_text, + "turn_count": len(conversation.turns) + } + + return {"error": "No response generated"} + + except Exception as e: + return {"error": f"Error generating response: {e}"} + +@mcp.tool() +def get_conversation_history(conversation_id: str) -> dict: + """Get the full history of a conversation.""" + conversation = conversation_manager.get_conversation(conversation_id) + if not conversation: + return {"error": f"Conversation {conversation_id} not found"} + + return { + "conversation_id": conversation_id, + "created_at": conversation.created_at.isoformat(), + "updated_at": conversation.updated_at.isoformat(), + "turn_count": len(conversation.turns), + "turns": [ + { + "id": turn.id, + "role": turn.role.value, + "content": turn.content, + "timestamp": turn.timestamp.isoformat(), + "metadata": turn.metadata + } + for turn in conversation.turns + ], + "metadata": conversation.metadata + } + +@mcp.tool() +def list_conversations() -> dict: + """List all active conversations.""" + return { + "conversations": conversation_manager.list_conversations(), + "total_count": len(conversation_manager.conversations) + } + +@mcp.tool() +async def conversation_summary(conversation_id: str) -> dict: + """Generate a summary of a conversation.""" + conversation = conversation_manager.get_conversation(conversation_id) + if not conversation: + return {"error": f"Conversation {conversation_id} not found"} + + if len(conversation.turns) < 2: + return {"error": "Not enough conversation turns to summarize"} + + # Build conversation text + conversation_text = "" + for turn in conversation.turns: + role_name = "User" if turn.role == Role.USER else "Assistant" + conversation_text += f"{role_name}: {turn.content}\\n\\n" + + prompt = f"""Please provide a concise summary of the following conversation: + +{conversation_text} + +Summary:""" + + message = SamplingMessage( + role=Role.USER, + content={"type": "text", "text": prompt} + ) + + try: + completion = await mcp.request_sampling( + messages=[message], + max_tokens=200, + temperature=0.3 + ) + + if completion and completion.content: + content = completion.content[0] + if hasattr(content, 'text'): + return { + "conversation_id": conversation_id, + "summary": content.text.strip(), + "turn_count": len(conversation.turns) + } + + return {"error": "Could not generate summary"} + + except Exception as e: + return {"error": f"Error generating summary: {e}"} +``` + +## Specialized completion workflows + +### Content generation workflows + +```python +""" +Specialized workflows for content generation. +""" + +from typing import List, Dict, Any +from enum import Enum + +class ContentType(str, Enum): + """Types of content that can be generated.""" + BLOG_POST = "blog_post" + EMAIL = "email" + SOCIAL_MEDIA = "social_media" + DOCUMENTATION = "documentation" + CREATIVE_WRITING = "creative_writing" + TECHNICAL_SPEC = "technical_spec" + +class ToneStyle(str, Enum): + """Tone and style options.""" + PROFESSIONAL = "professional" + CASUAL = "casual" + FRIENDLY = "friendly" + FORMAL = "formal" + TECHNICAL = "technical" + CREATIVE = "creative" + +@mcp.tool() +async def generate_content( + content_type: str, + topic: str, + tone: str = "professional", + length: str = "medium", + target_audience: str = "general", + key_points: List[str] = None +) -> dict: + """Generate content based on specifications.""" + + # Validate inputs + try: + content_type_enum = ContentType(content_type) + tone_enum = ToneStyle(tone) + except ValueError as e: + return {"error": f"Invalid parameter: {e}"} + + # Build prompt based on content type + prompt_templates = { + ContentType.BLOG_POST: """Write a {length} blog post about "{topic}" with a {tone} tone for {target_audience}. + +Key points to cover: +{key_points} + +Please include: +- Engaging title +- Clear introduction +- Well-structured body with subheadings +- Compelling conclusion +- Call to action + +Blog post:""", + + ContentType.EMAIL: """Write a {tone} email about "{topic}" for {target_audience}. + +Key points to include: +{key_points} + +Please include: +- Clear subject line +- Professional greeting +- Concise body +- Appropriate closing + +Email:""", + + ContentType.SOCIAL_MEDIA: """Create a {tone} social media post about "{topic}" for {target_audience}. + +Key messages: +{key_points} + +Requirements: +- Engaging and shareable +- Appropriate hashtags +- Call to action +- Platform-optimized length + +Post:""", + + ContentType.DOCUMENTATION: """Write technical documentation about "{topic}" with a {tone} approach for {target_audience}. + +Key topics to cover: +{key_points} + +Include: +- Clear overview +- Step-by-step instructions +- Examples +- Troubleshooting tips + +Documentation:""", + + ContentType.CREATIVE_WRITING: """Write a creative piece about "{topic}" with a {tone} style for {target_audience}. + +Elements to include: +{key_points} + +Style requirements: +- {length} length +- Engaging narrative +- Rich descriptions +- Compelling characters/scenes + +Story:""", + + ContentType.TECHNICAL_SPEC: """Create a technical specification for "{topic}" with {tone} language for {target_audience}. + +Specifications to include: +{key_points} + +Format: +- Executive summary +- Technical requirements +- Implementation details +- Acceptance criteria + +Specification:""" + } + + # Format key points + key_points_text = "\\n".join(f"- {point}" for point in (key_points or ["General information about the topic"])) + + # Get prompt template + prompt_template = prompt_templates.get(content_type_enum, prompt_templates[ContentType.BLOG_POST]) + + # Format prompt + prompt = prompt_template.format( + topic=topic, + tone=tone, + length=length, + target_audience=target_audience, + key_points=key_points_text + ) + + message = SamplingMessage( + role=Role.USER, + content={"type": "text", "text": prompt} + ) + + try: + # Adjust parameters based on content type + max_tokens = { + "short": 200, + "medium": 500, + "long": 1000 + }.get(length, 500) + + temperature = { + ToneStyle.CREATIVE: 0.8, + ToneStyle.CASUAL: 0.7, + ToneStyle.FRIENDLY: 0.6, + ToneStyle.PROFESSIONAL: 0.5, + ToneStyle.FORMAL: 0.4, + ToneStyle.TECHNICAL: 0.3 + }.get(tone_enum, 0.5) + + completion = await mcp.request_sampling( + messages=[message], + max_tokens=max_tokens, + temperature=temperature + ) + + if completion and completion.content: + content = completion.content[0] + if hasattr(content, 'text'): + generated_content = content.text.strip() + + return { + "content": generated_content, + "content_type": content_type, + "tone": tone, + "length": length, + "target_audience": target_audience, + "word_count": len(generated_content.split()), + "character_count": len(generated_content) + } + + return {"error": "No content generated"} + + except Exception as e: + return {"error": f"Error generating content: {e}"} + +@mcp.tool() +async def improve_content( + original_content: str, + improvement_type: str = "clarity", + target_audience: str = "general" +) -> dict: + """Improve existing content based on specified criteria.""" + + improvement_instructions = { + "clarity": "Make the content clearer and easier to understand", + "engagement": "Make the content more engaging and compelling", + "conciseness": "Make the content more concise while retaining key information", + "formality": "Make the content more formal and professional", + "casualness": "Make the content more casual and conversational", + "technical": "Make the content more technically detailed and precise", + "accessibility": "Make the content more accessible to a broader audience" + } + + instruction = improvement_instructions.get(improvement_type, improvement_instructions["clarity"]) + + prompt = f"""Please improve the following content by focusing on: {instruction} + +Target audience: {target_audience} + +Original content: +{original_content} + +Improved content:""" + + message = SamplingMessage( + role=Role.USER, + content={"type": "text", "text": prompt} + ) + + try: + completion = await mcp.request_sampling( + messages=[message], + max_tokens=len(original_content.split()) + 200, # Allow for expansion + temperature=0.4 + ) + + if completion and completion.content: + content = completion.content[0] + if hasattr(content, 'text'): + improved_content = content.text.strip() + + return { + "original_content": original_content, + "improved_content": improved_content, + "improvement_type": improvement_type, + "target_audience": target_audience, + "original_word_count": len(original_content.split()), + "improved_word_count": len(improved_content.split()), + "change_ratio": len(improved_content.split()) / len(original_content.split()) + } + + return {"error": "Could not improve content"} + + except Exception as e: + return {"error": f"Error improving content: {e}"} + +@mcp.tool() +async def generate_variations( + base_content: str, + variation_count: int = 3, + variation_type: str = "tone" +) -> dict: + """Generate multiple variations of content.""" + + if variation_count > 5: + return {"error": "Maximum 5 variations allowed"} + + variation_instructions = { + "tone": [ + "professional and formal", + "friendly and conversational", + "enthusiastic and energetic", + "calm and measured", + "authoritative and confident" + ], + "length": [ + "much more concise", + "more detailed and expanded", + "moderately shorter", + "significantly longer", + "with added examples" + ], + "style": [ + "more creative and artistic", + "more technical and precise", + "more storytelling focused", + "more data-driven", + "more action-oriented" + ] + } + + instructions = variation_instructions.get(variation_type, variation_instructions["tone"]) + + variations = [] + + for i in range(variation_count): + instruction = instructions[i % len(instructions)] + + prompt = f"""Please rewrite the following content to be {instruction}: + +Original content: +{base_content} + +Rewritten content:""" + + message = SamplingMessage( + role=Role.USER, + content={"type": "text", "text": prompt} + ) + + try: + completion = await mcp.request_sampling( + messages=[message], + max_tokens=len(base_content.split()) + 100, + temperature=0.6 + ) + + if completion and completion.content: + content = completion.content[0] + if hasattr(content, 'text'): + variations.append({ + "variation_id": i + 1, + "instruction": instruction, + "content": content.text.strip(), + "word_count": len(content.text.split()) + }) + + except Exception as e: + variations.append({ + "variation_id": i + 1, + "instruction": instruction, + "error": str(e) + }) + + return { + "base_content": base_content, + "variation_type": variation_type, + "variations": variations, + "base_word_count": len(base_content.split()) + } +``` + +## Advanced completion techniques + +### Structured generation + +```python +""" +Advanced completion techniques with structured output. +""" + +import json +from typing import Any, Dict, List, Optional +from pydantic import BaseModel, Field + +class GenerationConfig(BaseModel): + """Configuration for structured generation.""" + max_tokens: int = Field(default=500, ge=50, le=2000) + temperature: float = Field(default=0.7, ge=0.0, le=2.0) + format: str = Field(default="text", pattern="^(text|json|markdown|html)$") + include_reasoning: bool = Field(default=False) + quality_check: bool = Field(default=True) + +class StructuredPrompt(BaseModel): + """Structured prompt with constraints.""" + task: str = Field(..., description="The main task to accomplish") + context: Optional[str] = Field(None, description="Additional context") + constraints: List[str] = Field(default_factory=list, description="Generation constraints") + examples: List[str] = Field(default_factory=list, description="Example outputs") + output_schema: Optional[Dict[str, Any]] = Field(None, description="Expected output schema") + +@mcp.tool() +async def structured_generation( + prompt_config: Dict[str, Any], + generation_config: Dict[str, Any] = None +) -> Dict[str, Any]: + """Generate structured content with advanced controls.""" + + try: + # Validate configurations + prompt = StructuredPrompt(**prompt_config) + config = GenerationConfig(**(generation_config or {})) + + # Build structured prompt + system_parts = [ + f"Task: {prompt.task}" + ] + + if prompt.context: + system_parts.append(f"Context: {prompt.context}") + + if prompt.constraints: + system_parts.append("Constraints:") + system_parts.extend(f"- {constraint}" for constraint in prompt.constraints) + + if prompt.examples: + system_parts.append("Examples:") + system_parts.extend(f"Example: {example}" for example in prompt.examples) + + if config.format == "json" and prompt.output_schema: + system_parts.append(f"Output format: JSON following this schema: {json.dumps(prompt.output_schema)}") + elif config.format == "json": + system_parts.append("Output format: Valid JSON") + elif config.format == "markdown": + system_parts.append("Output format: Markdown") + elif config.format == "html": + system_parts.append("Output format: HTML") + + if config.include_reasoning: + system_parts.append("Please include your reasoning process before the final output.") + + if config.quality_check: + system_parts.append("Ensure high quality and accuracy in your response.") + + full_prompt = "\\n\\n".join(system_parts) + + message = SamplingMessage( + role=Role.USER, + content={"type": "text", "text": full_prompt} + ) + + completion = await mcp.request_sampling( + messages=[message], + max_tokens=config.max_tokens, + temperature=config.temperature + ) + + if completion and completion.content: + content = completion.content[0] + if hasattr(content, 'text'): + generated_text = content.text.strip() + + # Validate output format + validation_result = None + if config.format == "json": + try: + parsed_json = json.loads(generated_text) + validation_result = {"valid": True, "parsed": parsed_json} + + # Validate against schema if provided + if prompt.output_schema: + # Simple schema validation (could use jsonschema library) + validation_result["schema_valid"] = True + except json.JSONDecodeError as e: + validation_result = {"valid": False, "error": str(e)} + + return { + "success": True, + "generated_content": generated_text, + "format": config.format, + "validation": validation_result, + "config_used": config.dict(), + "prompt_used": prompt.dict(), + "word_count": len(generated_text.split()), + "character_count": len(generated_text) + } + + return {"success": False, "error": "No content generated"} + + except Exception as e: + return {"success": False, "error": f"Error in structured generation: {e}"} + +@mcp.tool() +async def chain_generation( + steps: List[Dict[str, Any]], + pass_outputs: bool = True +) -> Dict[str, Any]: + """Chain multiple generation steps together.""" + + if len(steps) > 10: + return {"error": "Maximum 10 steps allowed"} + + results = [] + accumulated_context = "" + + for i, step_config in enumerate(steps): + step_id = i + 1 + + try: + # Add accumulated context if enabled + if pass_outputs and accumulated_context: + if "context" in step_config: + step_config["context"] += f"\\n\\nPrevious outputs:\\n{accumulated_context}" + else: + step_config["context"] = f"Previous outputs:\\n{accumulated_context}" + + # Execute generation step + step_result = await structured_generation(step_config) + + if step_result.get("success"): + generated_content = step_result["generated_content"] + + results.append({ + "step_id": step_id, + "success": True, + "content": generated_content, + "config": step_config, + "details": step_result + }) + + # Add to accumulated context + if pass_outputs: + accumulated_context += f"\\nStep {step_id}: {generated_content}\\n" + else: + results.append({ + "step_id": step_id, + "success": False, + "error": step_result.get("error"), + "config": step_config + }) + break # Stop on error + + except Exception as e: + results.append({ + "step_id": step_id, + "success": False, + "error": str(e), + "config": step_config + }) + break + + return { + "chain_success": all(result["success"] for result in results), + "steps_completed": len(results), + "total_steps": len(steps), + "results": results, + "final_output": results[-1]["content"] if results and results[-1]["success"] else None + } + +@mcp.tool() +async def iterative_refinement( + initial_prompt: str, + refinement_instructions: List[str], + max_iterations: int = 3 +) -> Dict[str, Any]: + """Iteratively refine generated content.""" + + if max_iterations > 5: + return {"error": "Maximum 5 iterations allowed"} + + iterations = [] + current_content = "" + + # Generate initial content + message = SamplingMessage( + role=Role.USER, + content={"type": "text", "text": initial_prompt} + ) + + try: + completion = await mcp.request_sampling( + messages=[message], + max_tokens=500, + temperature=0.7 + ) + + if completion and completion.content: + content = completion.content[0] + if hasattr(content, 'text'): + current_content = content.text.strip() + + iterations.append({ + "iteration": 0, + "type": "initial", + "prompt": initial_prompt, + "content": current_content, + "word_count": len(current_content.split()) + }) + + # Apply refinements + for i, instruction in enumerate(refinement_instructions[:max_iterations]): + if not current_content: + break + + refinement_prompt = f"""Please refine the following content based on this instruction: {instruction} + +Current content: +{current_content} + +Refined content:""" + + message = SamplingMessage( + role=Role.USER, + content={"type": "text", "text": refinement_prompt} + ) + + try: + completion = await mcp.request_sampling( + messages=[message], + max_tokens=600, + temperature=0.5 + ) + + if completion and completion.content: + content = completion.content[0] + if hasattr(content, 'text'): + refined_content = content.text.strip() + + iterations.append({ + "iteration": i + 1, + "type": "refinement", + "instruction": instruction, + "previous_content": current_content, + "refined_content": refined_content, + "word_count": len(refined_content.split()), + "improvement": len(refined_content.split()) - len(current_content.split()) + }) + + current_content = refined_content + + except Exception as e: + iterations.append({ + "iteration": i + 1, + "type": "refinement", + "instruction": instruction, + "error": str(e) + }) + break + + return { + "initial_prompt": initial_prompt, + "refinement_instructions": refinement_instructions, + "iterations_completed": len(iterations), + "iterations": iterations, + "final_content": current_content, + "total_word_count": len(current_content.split()) if current_content else 0 + } + +if __name__ == "__main__": + mcp.run() +``` + +## Best practices + +### Design guidelines + +- **Clear prompts** - Write specific, unambiguous prompts +- **Context management** - Maintain relevant context across conversations +- **Error handling** - Gracefully handle completion failures +- **Rate limiting** - Implement appropriate rate limits for LLM calls +- **Cost optimization** - Monitor and optimize token usage + +### Performance optimization + +- **Prompt engineering** - Optimize prompts for better results +- **Temperature control** - Adjust temperature based on use case +- **Token management** - Efficiently manage max_tokens parameters +- **Caching** - Cache common completions to reduce API calls +- **Batch processing** - Group similar requests when possible + +### Quality assurance + +- **Output validation** - Validate generated content format and quality +- **Content filtering** - Filter inappropriate or irrelevant content +- **Fact checking** - Implement fact-checking for factual content +- **User feedback** - Collect feedback to improve generation quality +- **Version tracking** - Track prompt versions and performance + +## Next steps + +- **[Structured output](structured-output.md)** - Advanced output formatting +- **[Low-level server](low-level-server.md)** - Custom completion implementations +- **[Authentication](authentication.md)** - Secure LLM integrations +- **[Sampling](sampling.md)** - Understanding MCP sampling patterns \ No newline at end of file diff --git a/docs/context.md b/docs/context.md new file mode 100644 index 000000000..cddd9f273 --- /dev/null +++ b/docs/context.md @@ -0,0 +1,654 @@ +# Context + +The Context object provides tools and resources with access to request information, server capabilities, and communication channels. It's automatically injected into functions that request it. + +## What is context? + +Context gives your tools and resources access to: + +- **Request metadata** - IDs, client information, progress tokens +- **Logging capabilities** - Send structured log messages to clients +- **Progress reporting** - Update clients on long-running operations +- **Resource reading** - Access other resources from within tools +- **User interaction** - Request additional input through elicitation +- **Server information** - Access to server configuration and state + +## Basic context usage + +### Getting context in functions + +Add a parameter with the `Context` type annotation to any tool or resource: + +```python +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + +mcp = FastMCP("Context Example") + +@mcp.tool() +async def my_tool(data: str, ctx: Context[ServerSession, None]) -> str: + """Tool that uses context capabilities.""" + await ctx.info(f"Processing data: {data}") + return f"Processed: {data}" + +@mcp.resource("info://{type}") +async def get_info(type: str, ctx: Context) -> str: + """Resource that logs access.""" + await ctx.debug(f"Accessed info resource: {type}") + return f"Information about {type}" +``` + +### Context properties + +```python +@mcp.tool() +async def context_info(ctx: Context) -> dict: + """Get information from the context.""" + return { + "request_id": ctx.request_id, + "client_id": ctx.client_id, + "server_name": ctx.fastmcp.name, + "debug_mode": ctx.fastmcp.settings.debug + } +``` + +## Logging and notifications + +### Log levels + +```python +@mcp.tool() +async def demonstrate_logging(message: str, ctx: Context) -> str: + """Demonstrate different log levels.""" + # Debug information (usually filtered out in production) + await ctx.debug(f"Debug: Starting to process '{message}'") + + # General information + await ctx.info(f"Info: Processing message of length {len(message)}") + + # Warning about potential issues + if len(message) > 100: + await ctx.warning("Warning: Message is quite long, processing may take time") + + # Error conditions + if not message.strip(): + await ctx.error("Error: Empty message provided") + raise ValueError("Message cannot be empty") + + return f"Processed: {message}" + +@mcp.tool() +async def custom_logging(level: str, message: str, ctx: Context) -> str: + """Send log with custom level and logger name.""" + await ctx.log( + level=level, + message=message, + logger_name="custom.processor" + ) + return f"Logged {level}: {message}" +``` + +### Structured logging + +```python +@mcp.tool() +async def process_file(filename: str, ctx: Context) -> dict: + """Process a file with structured logging.""" + await ctx.info(f"Starting file processing: {filename}") + + try: + # Simulate file processing + file_size = len(filename) * 100 # Mock size calculation + + await ctx.debug(f"File size calculated: {file_size} bytes") + + if file_size > 1000: + await ctx.warning(f"Large file detected: {file_size} bytes") + + # Process file (simulated) + processed_lines = file_size // 50 + await ctx.info(f"Processing complete: {processed_lines} lines processed") + + return { + "filename": filename, + "size": file_size, + "lines_processed": processed_lines, + "status": "success" + } + + except Exception as e: + await ctx.error(f"File processing failed: {e}") + raise +``` + +## Progress reporting + +### Basic progress updates + +```python +import asyncio + +@mcp.tool() +async def long_task(steps: int, ctx: Context) -> str: + """Demonstrate progress reporting.""" + await ctx.info(f"Starting task with {steps} steps") + + for i in range(steps): + # Simulate work + await asyncio.sleep(0.1) + + # Report progress + progress = (i + 1) / steps + await ctx.report_progress( + progress=progress, + total=1.0, + message=f"Completed step {i + 1} of {steps}" + ) + + await ctx.debug(f"Step {i + 1} completed") + + await ctx.info("Task completed successfully") + return f"Finished all {steps} steps" +``` + +### Advanced progress tracking + +```python +@mcp.tool() +async def multi_phase_task( + phase_sizes: list[int], + ctx: Context +) -> dict[str, any]: + """Task with multiple phases and detailed progress.""" + total_steps = sum(phase_sizes) + completed_steps = 0 + + await ctx.info(f"Starting multi-phase task: {len(phase_sizes)} phases, {total_steps} total steps") + + results = {} + + for phase_num, phase_size in enumerate(phase_sizes, 1): + phase_name = f"Phase {phase_num}" + await ctx.info(f"Starting {phase_name} ({phase_size} steps)") + + for step in range(phase_size): + # Simulate work + await asyncio.sleep(0.05) + + completed_steps += 1 + overall_progress = completed_steps / total_steps + phase_progress = (step + 1) / phase_size + + await ctx.report_progress( + progress=overall_progress, + total=1.0, + message=f"{phase_name}: Step {step + 1}/{phase_size} (Overall: {completed_steps}/{total_steps})" + ) + + results[phase_name] = f"Completed {phase_size} steps" + await ctx.info(f"{phase_name} completed") + + return { + "total_steps": total_steps, + "phases_completed": len(phase_sizes), + "results": results, + "status": "success" + } +``` + +## Resource reading + +### Reading resources from tools + +```python +@mcp.resource("config://{section}") +def get_config(section: str) -> str: + """Get configuration for a section.""" + configs = { + "database": "host=localhost port=5432 dbname=myapp", + "cache": "redis://localhost:6379/0", + "logging": "level=INFO handler=file" + } + return configs.get(section, "Configuration not found") + +@mcp.tool() +async def process_with_config(operation: str, ctx: Context) -> str: + """Tool that reads configuration from resources.""" + try: + # Read database configuration + db_config = await ctx.read_resource("config://database") + db_content = db_config.contents[0] + + if hasattr(db_content, 'text'): + config_text = db_content.text + await ctx.info(f"Using database config: {config_text}") + + # Read logging configuration + log_config = await ctx.read_resource("config://logging") + log_content = log_config.contents[0] + + if hasattr(log_content, 'text'): + log_text = log_content.text + await ctx.debug(f"Logging config: {log_text}") + + # Perform operation with configuration + return f"Operation '{operation}' completed with loaded configuration" + + except Exception as e: + await ctx.error(f"Failed to read configuration: {e}") + raise ValueError(f"Configuration error: {e}") + +@mcp.tool() +async def analyze_resource(resource_uri: str, ctx: Context) -> dict: + """Analyze content from any resource.""" + try: + resource_content = await ctx.read_resource(resource_uri) + + analysis = { + "uri": resource_uri, + "content_blocks": len(resource_content.contents), + "types": [] + } + + for content in resource_content.contents: + if hasattr(content, 'text'): + analysis["types"].append("text") + word_count = len(content.text.split()) + analysis["word_count"] = word_count + await ctx.info(f"Analyzed text resource: {word_count} words") + elif hasattr(content, 'data'): + analysis["types"].append("binary") + data_size = len(content.data) if content.data else 0 + analysis["data_size"] = data_size + await ctx.info(f"Analyzed binary resource: {data_size} bytes") + + return analysis + + except Exception as e: + await ctx.error(f"Resource analysis failed: {e}") + raise +``` + +## User interaction through elicitation + +### Basic elicitation + +```python +from pydantic import BaseModel, Field + +class UserPreferences(BaseModel): + """Schema for collecting user preferences.""" + theme: str = Field(description="Preferred theme (light/dark)") + language: str = Field(description="Preferred language code") + notifications: bool = Field(description="Enable notifications?") + +@mcp.tool() +async def configure_settings(ctx: Context) -> dict: + """Configure user settings through elicitation.""" + await ctx.info("Collecting user preferences...") + + result = await ctx.elicit( + message="Please configure your preferences:", + schema=UserPreferences + ) + + if result.action == "accept" and result.data: + preferences = result.data + await ctx.info(f"Settings configured: theme={preferences.theme}, language={preferences.language}") + + return { + "status": "configured", + "theme": preferences.theme, + "language": preferences.language, + "notifications": preferences.notifications + } + elif result.action == "decline": + await ctx.info("User declined to configure settings") + return {"status": "declined", "using_defaults": True} + else: + await ctx.warning("Settings configuration was cancelled") + return {"status": "cancelled"} +``` + +### Advanced elicitation patterns + +```python +class BookingRequest(BaseModel): + """Schema for restaurant booking.""" + date: str = Field(description="Preferred date (YYYY-MM-DD)") + time: str = Field(description="Preferred time (HH:MM)") + party_size: int = Field(description="Number of people", ge=1, le=20) + special_requests: str = Field(default="", description="Any special requests") + +@mcp.tool() +async def book_restaurant( + restaurant: str, + initial_date: str, + ctx: Context +) -> dict: + """Book restaurant with fallback options.""" + await ctx.info(f"Checking availability at {restaurant} for {initial_date}") + + # Simulate availability check + if initial_date == "2024-12-25": # Christmas - likely busy + await ctx.warning(f"No availability on {initial_date}") + + result = await ctx.elicit( + message=f"Sorry, {restaurant} is fully booked on {initial_date}. Would you like to try a different date?", + schema=BookingRequest + ) + + if result.action == "accept" and result.data: + booking = result.data + await ctx.info(f"Alternative booking confirmed for {booking.date} at {booking.time}") + + return { + "status": "booked", + "restaurant": restaurant, + "date": booking.date, + "time": booking.time, + "party_size": booking.party_size, + "special_requests": booking.special_requests, + "confirmation_id": f"BK{hash(booking.date + booking.time) % 10000:04d}" + } + else: + return {"status": "cancelled", "reason": "No alternative date selected"} + + else: + # Direct booking for available date + return { + "status": "booked", + "restaurant": restaurant, + "date": initial_date, + "confirmation_id": f"BK{hash(initial_date) % 10000:04d}" + } +``` + +## Server and session access + +### Server information access + +```python +@mcp.tool() +def server_status(ctx: Context) -> dict: + """Get detailed server status information.""" + settings = ctx.fastmcp.settings + + return { + "server": { + "name": ctx.fastmcp.name, + "instructions": ctx.fastmcp.instructions, + "debug_mode": settings.debug, + "log_level": settings.log_level + }, + "network": { + "host": settings.host, + "port": settings.port, + "mount_path": settings.mount_path, + "sse_path": settings.sse_path + }, + "features": { + "stateless_http": settings.stateless_http, + "json_response": getattr(settings, 'json_response', False) + } + } +``` + +### Session information + +```python +@mcp.tool() +def session_info(ctx: Context) -> dict: + """Get information about the current session.""" + session = ctx.session + + info = { + "request_id": ctx.request_id, + "client_id": ctx.client_id + } + + # Access client capabilities if available + if hasattr(session, 'client_params'): + client_params = session.client_params + info["client_capabilities"] = { + "name": getattr(client_params, 'clientInfo', {}).get('name', 'Unknown'), + "version": getattr(client_params, 'clientInfo', {}).get('version', 'Unknown') + } + + return info +``` + +## Lifespan context access + +### Accessing lifespan resources + +```python +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +class DatabaseConnection: + """Mock database connection.""" + def __init__(self, connection_string: str): + self.connection_string = connection_string + self.is_connected = False + + async def connect(self): + self.is_connected = True + return self + + async def disconnect(self): + self.is_connected = False + + async def query(self, sql: str) -> list[dict]: + if not self.is_connected: + raise RuntimeError("Database not connected") + return [{"id": 1, "name": "test", "sql": sql}] + +@dataclass +class AppContext: + """Application context with shared resources.""" + db: DatabaseConnection + api_key: str + cache_enabled: bool + +@asynccontextmanager +async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: + """Manage application lifecycle.""" + # Startup + db = DatabaseConnection("postgresql://localhost/myapp") + await db.connect() + + context = AppContext( + db=db, + api_key="secret-api-key-123", + cache_enabled=True + ) + + try: + yield context + finally: + # Shutdown + await db.disconnect() + +mcp = FastMCP("Database App", lifespan=app_lifespan) + +@mcp.tool() +async def query_data( + sql: str, + ctx: Context[ServerSession, AppContext] +) -> dict: + """Query database using lifespan context.""" + # Access lifespan context + app_ctx = ctx.request_context.lifespan_context + + await ctx.info(f"Executing query with API key: {app_ctx.api_key[:10]}...") + + if app_ctx.cache_enabled: + await ctx.debug("Cache is enabled for this query") + + # Use database connection from lifespan + results = await app_ctx.db.query(sql) + + await ctx.info(f"Query returned {len(results)} rows") + + return { + "sql": sql, + "results": results, + "cached": app_ctx.cache_enabled, + "connection_status": app_ctx.db.is_connected + } +``` + +## Advanced context patterns + +### Context middleware pattern + +```python +from functools import wraps + +def with_timing(func): + """Decorator to add timing information to context operations.""" + @wraps(func) + async def wrapper(*args, **kwargs): + # Find context in arguments + ctx = None + for arg in args: + if isinstance(arg, Context): + ctx = arg + break + + if ctx: + import time + start_time = time.time() + await ctx.debug(f"Starting {func.__name__}") + + try: + result = await func(*args, **kwargs) + duration = time.time() - start_time + await ctx.info(f"Completed {func.__name__} in {duration:.2f}s") + return result + except Exception as e: + duration = time.time() - start_time + await ctx.error(f"Failed {func.__name__} after {duration:.2f}s: {e}") + raise + else: + return await func(*args, **kwargs) + + return wrapper + +@mcp.tool() +@with_timing +async def timed_operation(data: str, ctx: Context) -> str: + """Operation with automatic timing.""" + await asyncio.sleep(0.5) # Simulate work + return f"Processed: {data}" +``` + +### Context validation + +```python +def require_debug_mode(func): + """Decorator to require debug mode for certain operations.""" + @wraps(func) + async def wrapper(*args, **kwargs): + ctx = None + for arg in args: + if isinstance(arg, Context): + ctx = arg + break + + if ctx and not ctx.fastmcp.settings.debug: + await ctx.error("Debug mode required for this operation") + raise ValueError("Debug mode required") + + return await func(*args, **kwargs) + + return wrapper + +@mcp.tool() +@require_debug_mode +async def debug_operation(ctx: Context) -> dict: + """Operation that requires debug mode.""" + await ctx.info("Performing debug operation") + return {"debug_info": "sensitive debug data"} +``` + +## Testing context functionality + +### Mocking context for testing + +```python +import pytest +from unittest.mock import AsyncMock, Mock + +@pytest.mark.asyncio +async def test_tool_with_context(): + # Create mock context + mock_ctx = Mock() + mock_ctx.info = AsyncMock() + mock_ctx.debug = AsyncMock() + mock_ctx.request_id = "test-123" + + # Test the tool function + @mcp.tool() + async def test_tool(data: str, ctx: Context) -> str: + await ctx.info(f"Processing: {data}") + return f"Result: {data}" + + result = await test_tool("test data", mock_ctx) + + assert result == "Result: test data" + mock_ctx.info.assert_called_once_with("Processing: test data") + +@pytest.mark.asyncio +async def test_progress_reporting(): + mock_ctx = Mock() + mock_ctx.report_progress = AsyncMock() + mock_ctx.info = AsyncMock() + + @mcp.tool() + async def progress_tool(steps: int, ctx: Context) -> str: + for i in range(steps): + await ctx.report_progress( + progress=(i + 1) / steps, + total=1.0, + message=f"Step {i + 1}" + ) + return "Complete" + + result = await progress_tool(3, mock_ctx) + + assert result == "Complete" + assert mock_ctx.report_progress.call_count == 3 +``` + +## Best practices + +### Context usage guidelines + +- **Check context availability** - Not all functions need context +- **Use appropriate log levels** - Debug for detailed info, info for general updates +- **Handle context errors gracefully** - Don't assume context operations always succeed +- **Minimize context overhead** - Don't over-log or spam progress updates + +### Performance considerations + +- **Async context operations** - All context methods are async, use await +- **Batch logging** - Group related log messages when possible +- **Progress update frequency** - Update progress reasonably, not on every tiny step +- **Resource reading caching** - Cache frequently accessed resource content + +### Security considerations + +- **Sensitive data in logs** - Never log passwords, tokens, or personal data +- **Context information exposure** - Be careful what server info you expose +- **Elicitation data validation** - Always validate data from user elicitation +- **Resource access control** - Validate resource URIs in read_resource calls + +## Next steps + +- **[Server lifecycle](servers.md)** - Understanding server startup and shutdown +- **[Advanced tools](tools.md)** - Building complex tools with context +- **[Progress patterns](progress-logging.md)** - Advanced progress reporting techniques +- **[Authentication context](authentication.md)** - Using context with authenticated requests \ No newline at end of file diff --git a/docs/display-utilities.md b/docs/display-utilities.md new file mode 100644 index 000000000..35cdac71a --- /dev/null +++ b/docs/display-utilities.md @@ -0,0 +1,1287 @@ +# Display utilities + +Learn how to create user-friendly display utilities for MCP client applications, including formatters, visualizers, and interactive components. + +## Overview + +Display utilities provide: + +- **Rich formatting** - Beautiful output for terminal and web interfaces +- **Data visualization** - Charts, tables, and graphs from MCP data +- **Interactive components** - Progress bars, menus, and forms +- **Multi-format output** - HTML, markdown, JSON, and plain text + +## Text formatting + +### Rich console output + +```python +""" +Rich text formatting for MCP client output. +""" + +from rich.console import Console +from rich.table import Table +from rich.progress import Progress, TaskID +from rich.panel import Panel +from rich.syntax import Syntax +from rich.tree import Tree +import json + +class McpFormatter: + """Rich formatter for MCP client output.""" + + def __init__(self): + self.console = Console() + + def format_server_info(self, server_info: dict): + """Format server information.""" + panel = Panel.fit( + f"[bold cyan]{server_info.get('name', 'Unknown Server')}[/bold cyan]\n" + f"Version: {server_info.get('version', 'Unknown')}\n" + f"Protocol: {server_info.get('protocolVersion', 'Unknown')}", + title="[bold]Server Info[/bold]", + border_style="blue" + ) + self.console.print(panel) + + def format_tools_list(self, tools: list): + """Format tools list as a table.""" + table = Table(title="Available Tools") + table.add_column("Name", style="cyan", no_wrap=True) + table.add_column("Description", style="white") + table.add_column("Schema", style="dim") + + for tool in tools: + schema_preview = self._format_schema_preview(tool.get('inputSchema', {})) + table.add_row( + tool['name'], + tool.get('description', 'No description'), + schema_preview + ) + + self.console.print(table) + + def format_resources_list(self, resources: list): + """Format resources list as a tree.""" + tree = Tree("[bold]Resources[/bold]") + + # Group by scheme + schemes = {} + for resource in resources: + uri = resource.get('uri', '') + scheme = uri.split('://')[0] if '://' in uri else 'unknown' + if scheme not in schemes: + schemes[scheme] = [] + schemes[scheme].append(resource) + + for scheme, scheme_resources in schemes.items(): + scheme_branch = tree.add(f"[bold blue]{scheme}://[/bold blue]") + for resource in scheme_resources: + uri = resource.get('uri', '') + path = uri.split('://', 1)[-1] if '://' in uri else uri + name = resource.get('name', path) + description = resource.get('description', '') + + resource_text = f"[cyan]{name}[/cyan]" + if description: + resource_text += f" - {description}" + + scheme_branch.add(resource_text) + + self.console.print(tree) + + def format_tool_result(self, tool_name: str, result: dict): + """Format tool execution result.""" + success = result.get('success', True) + + # Header + status = "[green]✓[/green]" if success else "[red]✗[/red]" + self.console.print(f"\n{status} [bold]{tool_name}[/bold]") + + # Content + if 'content' in result: + for item in result['content']: + if isinstance(item, str): + self.console.print(f" {item}") + else: + self.console.print(f" {json.dumps(item, indent=2)}") + + # Structured output + if 'structured' in result and result['structured']: + self.console.print("\n[dim]Structured Output:[/dim]") + syntax = Syntax( + json.dumps(result['structured'], indent=2), + "json", + theme="monokai" + ) + self.console.print(syntax) + + # Error details + if not success and 'error' in result: + self.console.print(f"[red]Error: {result['error']}[/red]") + + def _format_schema_preview(self, schema: dict) -> str: + """Create a preview of the input schema.""" + if not schema or 'properties' not in schema: + return "No parameters" + + props = schema['properties'] + required = schema.get('required', []) + + preview_parts = [] + for prop_name, prop_info in list(props.items())[:3]: # Show first 3 + prop_type = prop_info.get('type', 'any') + is_required = prop_name in required + + prop_text = f"{prop_name}: {prop_type}" + if is_required: + prop_text = f"[bold]{prop_text}[/bold]" + + preview_parts.append(prop_text) + + preview = ", ".join(preview_parts) + if len(props) > 3: + preview += f", ... (+{len(props) - 3} more)" + + return preview + + def show_progress(self, description: str) -> TaskID: + """Show a progress bar.""" + progress = Progress() + task_id = progress.add_task(description, total=100) + progress.start() + return task_id + +# Usage example +async def formatted_client_example(): + """Example client with rich formatting.""" + formatter = McpFormatter() + + async with streamablehttp_client("http://localhost:8000/mcp") as (read, write, _): + async with ClientSession(read, write) as session: + # Initialize + init_result = await session.initialize() + formatter.format_server_info(init_result.serverInfo.__dict__) + + # List and format tools + tools = await session.list_tools() + formatter.format_tools_list([tool.__dict__ for tool in tools.tools]) + + # List and format resources + resources = await session.list_resources() + formatter.format_resources_list([res.__dict__ for res in resources.resources]) + + # Call tool with formatted output + if tools.tools: + result = await session.call_tool(tools.tools[0].name, {"test": "value"}) + formatter.format_tool_result( + tools.tools[0].name, + { + "success": not result.isError, + "content": [item.text for item in result.content if hasattr(item, 'text')] + } + ) + +if __name__ == "__main__": + import asyncio + asyncio.run(formatted_client_example()) +``` + +### Plain text formatting + +```python +""" +Simple text formatting for basic terminals. +""" + +class SimpleFormatter: + """Simple text formatter for basic output.""" + + def __init__(self, width: int = 80): + self.width = width + + def format_server_info(self, server_info: dict): + """Format server information.""" + print("=" * self.width) + print(f"SERVER: {server_info.get('name', 'Unknown')}") + print(f"Version: {server_info.get('version', 'Unknown')}") + print(f"Protocol: {server_info.get('protocolVersion', 'Unknown')}") + print("=" * self.width) + + def format_tools_list(self, tools: list): + """Format tools as a simple list.""" + print("\nAVAILABLE TOOLS:") + print("-" * 40) + + for i, tool in enumerate(tools, 1): + print(f"{i:2d}. {tool['name']}") + if tool.get('description'): + # Word wrap description + desc = tool['description'] + wrapped = self._wrap_text(desc, self.width - 6) + for line in wrapped: + print(f" {line}") + print() + + def format_resources_list(self, resources: list): + """Format resources as a simple list.""" + print("\nAVAILABLE RESOURCES:") + print("-" * 40) + + for i, resource in enumerate(resources, 1): + uri = resource.get('uri', '') + name = resource.get('name', uri) + print(f"{i:2d}. {name}") + print(f" URI: {uri}") + if resource.get('description'): + desc_lines = self._wrap_text(resource['description'], self.width - 6) + for line in desc_lines: + print(f" {line}") + print() + + def format_tool_result(self, tool_name: str, result: dict): + """Format tool result.""" + success = result.get('success', True) + status = "SUCCESS" if success else "ERROR" + + print(f"\nTOOL RESULT: {tool_name} [{status}]") + print("-" * 40) + + if 'content' in result: + for item in result['content']: + if isinstance(item, str): + for line in self._wrap_text(item, self.width): + print(line) + else: + print(json.dumps(item, indent=2)) + + if 'error' in result: + print(f"ERROR: {result['error']}") + + def _wrap_text(self, text: str, width: int) -> list[str]: + """Simple text wrapping.""" + words = text.split() + lines = [] + current_line = [] + current_length = 0 + + for word in words: + if current_length + len(word) + 1 > width: + if current_line: + lines.append(" ".join(current_line)) + current_line = [word] + current_length = len(word) + else: + lines.append(word[:width]) + else: + current_line.append(word) + current_length += len(word) + (1 if current_line else 0) + + if current_line: + lines.append(" ".join(current_line)) + + return lines + +# Usage example with simple formatting +def simple_client_example(): + """Example with simple text formatting.""" + formatter = SimpleFormatter() + + # Mock data for demonstration + server_info = { + "name": "Example MCP Server", + "version": "1.0.0", + "protocolVersion": "2025-06-18" + } + + tools = [ + { + "name": "calculate", + "description": "Perform mathematical calculations with support for basic arithmetic operations including addition, subtraction, multiplication, and division." + }, + { + "name": "format_text", + "description": "Format text with various options like uppercase, lowercase, title case, and more." + } + ] + + formatter.format_server_info(server_info) + formatter.format_tools_list(tools) +``` + +## Data visualization + +### Charts and graphs + +```python +""" +Data visualization utilities for MCP results. +""" + +import matplotlib.pyplot as plt +import pandas as pd +from typing import Any, Dict, List +import json +from io import BytesIO +import base64 + +class McpVisualizer: + """Data visualization for MCP results.""" + + def __init__(self, style: str = "seaborn-v0_8"): + plt.style.use(style) + self.fig_size = (10, 6) + + def visualize_data(self, data: Any, chart_type: str = "auto") -> str: + """Create visualization from MCP data.""" + if isinstance(data, dict): + return self._visualize_dict(data, chart_type) + elif isinstance(data, list): + return self._visualize_list(data, chart_type) + else: + return self._create_text_chart(str(data)) + + def _visualize_dict(self, data: dict, chart_type: str) -> str: + """Visualize dictionary data.""" + # Check if it's time series data + if self._is_time_series(data): + return self._create_time_series_chart(data) + + # Check if it's categorical data + if self._is_categorical(data): + if chart_type == "pie": + return self._create_pie_chart(data) + else: + return self._create_bar_chart(data) + + # Default to table + return self._create_table_chart(data) + + def _visualize_list(self, data: list, chart_type: str) -> str: + """Visualize list data.""" + if not data: + return self._create_text_chart("No data to display") + + # Check if it's a list of numbers + if all(isinstance(x, (int, float)) for x in data): + if chart_type == "histogram": + return self._create_histogram(data) + else: + return self._create_line_chart(data) + + # Check if it's a list of dictionaries + if all(isinstance(x, dict) for x in data): + return self._create_dataframe_chart(data) + + # Default to text representation + return self._create_text_chart("\\n".join(str(x) for x in data)) + + def _is_time_series(self, data: dict) -> bool: + """Check if data represents time series.""" + time_keys = {'time', 'date', 'timestamp', 'datetime'} + return any(key.lower() in time_keys for key in data.keys()) + + def _is_categorical(self, data: dict) -> bool: + """Check if data represents categorical values.""" + return all(isinstance(v, (int, float)) for v in data.values()) + + def _create_bar_chart(self, data: dict) -> str: + """Create bar chart from dictionary.""" + fig, ax = plt.subplots(figsize=self.fig_size) + + keys = list(data.keys()) + values = list(data.values()) + + bars = ax.bar(keys, values) + ax.set_title("Data Distribution") + ax.set_xlabel("Categories") + ax.set_ylabel("Values") + + # Add value labels on bars + for bar, value in zip(bars, values): + height = bar.get_height() + ax.text(bar.get_x() + bar.get_width()/2., height, + f'{value}', ha='center', va='bottom') + + plt.xticks(rotation=45, ha='right') + plt.tight_layout() + + return self._fig_to_base64() + + def _create_pie_chart(self, data: dict) -> str: + """Create pie chart from dictionary.""" + fig, ax = plt.subplots(figsize=self.fig_size) + + labels = list(data.keys()) + sizes = list(data.values()) + + ax.pie(sizes, labels=labels, autopct='%1.1f%%', startangle=90) + ax.set_title("Data Distribution") + + plt.tight_layout() + return self._fig_to_base64() + + def _create_line_chart(self, data: list) -> str: + """Create line chart from list of numbers.""" + fig, ax = plt.subplots(figsize=self.fig_size) + + ax.plot(range(len(data)), data, marker='o') + ax.set_title("Data Trend") + ax.set_xlabel("Index") + ax.set_ylabel("Value") + ax.grid(True, alpha=0.3) + + plt.tight_layout() + return self._fig_to_base64() + + def _create_histogram(self, data: list) -> str: + """Create histogram from list of numbers.""" + fig, ax = plt.subplots(figsize=self.fig_size) + + ax.hist(data, bins=min(20, len(data)//2), alpha=0.7, edgecolor='black') + ax.set_title("Data Distribution") + ax.set_xlabel("Value") + ax.set_ylabel("Frequency") + ax.grid(True, alpha=0.3) + + plt.tight_layout() + return self._fig_to_base64() + + def _create_dataframe_chart(self, data: list) -> str: + """Create chart from list of dictionaries.""" + df = pd.DataFrame(data) + + fig, ax = plt.subplots(figsize=self.fig_size) + + # Try to create a meaningful visualization + numeric_columns = df.select_dtypes(include=['number']).columns + + if len(numeric_columns) >= 2: + # Scatter plot for two numeric columns + x_col, y_col = numeric_columns[0], numeric_columns[1] + ax.scatter(df[x_col], df[y_col], alpha=0.6) + ax.set_xlabel(x_col) + ax.set_ylabel(y_col) + ax.set_title(f"{y_col} vs {x_col}") + elif len(numeric_columns) == 1: + # Line plot for single numeric column + col = numeric_columns[0] + ax.plot(df.index, df[col], marker='o') + ax.set_xlabel("Index") + ax.set_ylabel(col) + ax.set_title(f"{col} Trend") + else: + # Count plot for categorical data + first_col = df.columns[0] + value_counts = df[first_col].value_counts() + ax.bar(value_counts.index, value_counts.values) + ax.set_xlabel(first_col) + ax.set_ylabel("Count") + ax.set_title(f"{first_col} Distribution") + plt.xticks(rotation=45, ha='right') + + plt.tight_layout() + return self._fig_to_base64() + + def _create_table_chart(self, data: dict) -> str: + """Create table visualization.""" + fig, ax = plt.subplots(figsize=self.fig_size) + ax.axis('tight') + ax.axis('off') + + # Convert dict to table data + table_data = [[str(k), str(v)] for k, v in data.items()] + + table = ax.table( + cellText=table_data, + colLabels=['Key', 'Value'], + cellLoc='left', + loc='center' + ) + table.auto_set_font_size(False) + table.set_fontsize(10) + table.scale(1.2, 1.5) + + ax.set_title("Data Table") + + plt.tight_layout() + return self._fig_to_base64() + + def _create_text_chart(self, text: str) -> str: + """Create text-based chart.""" + fig, ax = plt.subplots(figsize=self.fig_size) + ax.text(0.5, 0.5, text, ha='center', va='center', + transform=ax.transAxes, fontsize=12, + bbox=dict(boxstyle="round,pad=0.3", facecolor="lightgray")) + ax.axis('off') + ax.set_title("Text Output") + + plt.tight_layout() + return self._fig_to_base64() + + def _fig_to_base64(self) -> str: + """Convert matplotlib figure to base64 string.""" + buffer = BytesIO() + plt.savefig(buffer, format='png', dpi=150, bbox_inches='tight') + buffer.seek(0) + + image_base64 = base64.b64encode(buffer.getvalue()).decode() + plt.close() + + return f"data:image/png;base64,{image_base64}" + +# Usage example +def visualization_example(): + """Example of data visualization.""" + visualizer = McpVisualizer() + + # Sample data from MCP tool results + sample_data = [ + {"month": "Jan", "sales": 1200, "profit": 200}, + {"month": "Feb", "sales": 1500, "profit": 300}, + {"month": "Mar", "sales": 1100, "profit": 150}, + {"month": "Apr", "sales": 1800, "profit": 400}, + {"month": "May", "sales": 2000, "profit": 500} + ] + + # Create visualization + chart_data_uri = visualizer.visualize_data(sample_data) + + # In a web context, you could embed this as: + # Data Visualization + + print(f"Chart created: {len(chart_data_uri)} characters") + + # Simple categorical data + categorical_data = {"Product A": 45, "Product B": 32, "Product C": 23} + pie_chart = visualizer.visualize_data(categorical_data, "pie") + print(f"Pie chart created: {len(pie_chart)} characters") + +if __name__ == "__main__": + visualization_example() +``` + +## Interactive components + +### Progress tracking + +```python +""" +Interactive progress tracking for long-running MCP operations. +""" + +import asyncio +import time +from typing import Callable, Any +from contextlib import asynccontextmanager + +class ProgressTracker: + """Track progress of MCP operations.""" + + def __init__(self, display_type: str = "rich"): + self.display_type = display_type + self.active_tasks = {} + + @asynccontextmanager + async def track_operation(self, description: str, total_steps: int = 100): + """Context manager for tracking operation progress.""" + task_id = id(asyncio.current_task()) + + if self.display_type == "rich": + from rich.progress import Progress, TaskID + progress = Progress() + progress.start() + progress_task = progress.add_task(description, total=total_steps) + else: + progress = SimpleProgress(description, total_steps) + progress_task = None + + self.active_tasks[task_id] = { + 'progress': progress, + 'task': progress_task, + 'current': 0, + 'total': total_steps + } + + try: + yield ProgressUpdater(self, task_id) + finally: + if self.display_type == "rich": + progress.stop() + else: + progress.finish() + del self.active_tasks[task_id] + + def update(self, task_id: int, advance: int = 1, message: str = None): + """Update progress for a task.""" + if task_id not in self.active_tasks: + return + + task_info = self.active_tasks[task_id] + task_info['current'] += advance + + if self.display_type == "rich": + progress = task_info['progress'] + progress_task = task_info['task'] + progress.update(progress_task, advance=advance, description=message) + else: + progress = task_info['progress'] + progress.update(task_info['current'], message) + +class ProgressUpdater: + """Helper class for updating progress.""" + + def __init__(self, tracker: ProgressTracker, task_id: int): + self.tracker = tracker + self.task_id = task_id + + def advance(self, steps: int = 1, message: str = None): + """Advance progress by specified steps.""" + self.tracker.update(self.task_id, steps, message) + + def set_message(self, message: str): + """Update progress message without advancing.""" + self.tracker.update(self.task_id, 0, message) + +class SimpleProgress: + """Simple text-based progress display.""" + + def __init__(self, description: str, total: int): + self.description = description + self.total = total + self.current = 0 + self.start_time = time.time() + print(f"Starting: {description}") + + def update(self, current: int, message: str = None): + """Update progress display.""" + self.current = current + percentage = (current / self.total) * 100 + elapsed = time.time() - self.start_time + + # Create simple progress bar + bar_length = 40 + filled_length = int(bar_length * current // self.total) + bar = '█' * filled_length + '░' * (bar_length - filled_length) + + status = f"\\r{self.description}: |{bar}| {percentage:.1f}% ({current}/{self.total})" + if message: + status += f" - {message}" + + print(status, end='', flush=True) + + def finish(self): + """Finish progress display.""" + elapsed = time.time() - self.start_time + print(f"\\nCompleted in {elapsed:.1f}s") + +# Usage example with MCP operations +async def progress_example(): + """Example of progress tracking with MCP operations.""" + tracker = ProgressTracker("simple") # or "rich" + + async with tracker.track_operation("Processing data", 100) as progress: + # Simulate MCP tool calls with progress updates + for i in range(10): + progress.set_message(f"Processing batch {i+1}/10") + + # Simulate tool call + await asyncio.sleep(0.2) + + # Update progress + progress.advance(10) + + progress.set_message("Finalizing results") + await asyncio.sleep(0.1) + +if __name__ == "__main__": + asyncio.run(progress_example()) +``` + +### Interactive menus + +```python +""" +Interactive menu system for MCP client applications. +""" + +import asyncio +from typing import List, Callable, Any, Optional + +class MenuItem: + """Represents a menu item.""" + + def __init__( + self, + key: str, + label: str, + action: Callable, + description: str = "" + ): + self.key = key + self.label = label + self.action = action + self.description = description + +class InteractiveMenu: + """Interactive menu for MCP client operations.""" + + def __init__(self, title: str = "MCP Client Menu"): + self.title = title + self.items: List[MenuItem] = [] + self.running = True + + def add_item(self, key: str, label: str, action: Callable, description: str = ""): + """Add a menu item.""" + self.items.append(MenuItem(key, label, action, description)) + + def add_separator(self): + """Add a menu separator.""" + self.items.append(MenuItem("", "---", None, "")) + + async def show(self): + """Display and run the interactive menu.""" + while self.running: + self._display_menu() + choice = await self._get_user_input() + await self._handle_choice(choice) + + def _display_menu(self): + """Display the menu options.""" + print("\\n" + "=" * 60) + print(f" {self.title}") + print("=" * 60) + + for item in self.items: + if item.key == "": + print(f" {item.label}") + else: + print(f" [{item.key}] {item.label}") + if item.description: + print(f" {item.description}") + + print("\\n [q] Quit") + print("=" * 60) + + async def _get_user_input(self) -> str: + """Get user input asynchronously.""" + # In a real application, you might use aioconsole for async input + import sys + try: + return input("Select option: ").strip().lower() + except (EOFError, KeyboardInterrupt): + return "q" + + async def _handle_choice(self, choice: str): + """Handle user menu choice.""" + if choice == "q": + self.running = False + print("Goodbye!") + return + + # Find matching menu item + for item in self.items: + if item.key == choice and item.action: + try: + if asyncio.iscoroutinefunction(item.action): + await item.action() + else: + item.action() + except Exception as e: + print(f"Error executing {item.label}: {e}") + return + + print(f"Invalid option: {choice}") + +# Example MCP client with interactive menu +class McpClientMenu: + """Interactive MCP client with menu interface.""" + + def __init__(self): + self.session: Optional[ClientSession] = None + self.connected = False + self.menu = InteractiveMenu("MCP Client") + self._setup_menu() + + def _setup_menu(self): + """Setup menu items.""" + self.menu.add_item("c", "Connect to Server", self._connect_server, + "Connect to an MCP server") + self.menu.add_item("d", "Disconnect", self._disconnect_server, + "Disconnect from current server") + self.menu.add_separator() + self.menu.add_item("t", "List Tools", self._list_tools, + "Show available tools") + self.menu.add_item("r", "List Resources", self._list_resources, + "Show available resources") + self.menu.add_item("p", "List Prompts", self._list_prompts, + "Show available prompts") + self.menu.add_separator() + self.menu.add_item("x", "Execute Tool", self._execute_tool, + "Call a tool with parameters") + self.menu.add_item("g", "Get Resource", self._get_resource, + "Read a resource") + self.menu.add_item("m", "Get Prompt", self._get_prompt, + "Get a prompt template") + self.menu.add_separator() + self.menu.add_item("s", "Server Status", self._server_status, + "Show server information") + + async def run(self): + """Run the interactive client.""" + print("Welcome to the MCP Interactive Client!") + await self.menu.show() + + async def _connect_server(self): + """Connect to MCP server.""" + if self.connected: + print("Already connected to a server. Disconnect first.") + return + + server_url = input("Enter server URL (http://localhost:8000/mcp): ").strip() + if not server_url: + server_url = "http://localhost:8000/mcp" + + try: + print(f"Connecting to {server_url}...") + + # This would use the actual MCP client + # async with streamablehttp_client(server_url) as (read, write, _): + # self.session = ClientSession(read, write) + # await self.session.__aenter__() + # await self.session.initialize() + + # Mock connection for demo + await asyncio.sleep(1) + self.connected = True + print("✓ Connected successfully!") + + except Exception as e: + print(f"✗ Connection failed: {e}") + + async def _disconnect_server(self): + """Disconnect from server.""" + if not self.connected: + print("Not connected to any server.") + return + + try: + # if self.session: + # await self.session.__aexit__(None, None, None) + # self.session = None + + # Mock disconnection + await asyncio.sleep(0.5) + self.connected = False + print("✓ Disconnected successfully!") + + except Exception as e: + print(f"✗ Disconnection failed: {e}") + + async def _list_tools(self): + """List available tools.""" + if not self.connected: + print("Not connected to server.") + return + + print("Fetching tools...") + + # Mock tool list + tools = [ + {"name": "calculate", "description": "Perform calculations"}, + {"name": "format_text", "description": "Format text strings"}, + {"name": "get_weather", "description": "Get weather information"} + ] + + print("\\nAvailable Tools:") + for i, tool in enumerate(tools, 1): + print(f" {i}. {tool['name']} - {tool['description']}") + + async def _list_resources(self): + """List available resources.""" + if not self.connected: + print("Not connected to server.") + return + + print("Fetching resources...") + + # Mock resource list + resources = [ + {"uri": "config://settings", "name": "Server Settings"}, + {"uri": "data://users", "name": "User Database"}, + {"uri": "logs://recent", "name": "Recent Logs"} + ] + + print("\\nAvailable Resources:") + for i, resource in enumerate(resources, 1): + print(f" {i}. {resource['name']} ({resource['uri']})") + + async def _list_prompts(self): + """List available prompts.""" + if not self.connected: + print("Not connected to server.") + return + + print("Fetching prompts...") + + # Mock prompt list + prompts = [ + {"name": "analyze_data", "description": "Data analysis prompt"}, + {"name": "code_review", "description": "Code review prompt"}, + {"name": "summarize", "description": "Text summarization prompt"} + ] + + print("\\nAvailable Prompts:") + for i, prompt in enumerate(prompts, 1): + print(f" {i}. {prompt['name']} - {prompt['description']}") + + async def _execute_tool(self): + """Execute a tool.""" + if not self.connected: + print("Not connected to server.") + return + + tool_name = input("Enter tool name: ").strip() + if not tool_name: + print("Tool name required.") + return + + print(f"Enter parameters for {tool_name} (JSON format):") + params_str = input("Parameters: ").strip() + + try: + import json + params = json.loads(params_str) if params_str else {} + except json.JSONDecodeError: + print("Invalid JSON parameters.") + return + + print(f"Executing {tool_name} with parameters: {params}") + + # Mock tool execution + await asyncio.sleep(1) + result = f"Tool {tool_name} executed successfully with result: 42" + print(f"Result: {result}") + + async def _get_resource(self): + """Get a resource.""" + if not self.connected: + print("Not connected to server.") + return + + uri = input("Enter resource URI: ").strip() + if not uri: + print("Resource URI required.") + return + + print(f"Fetching resource: {uri}") + + # Mock resource fetch + await asyncio.sleep(0.5) + content = f"Content of resource {uri}: This is sample resource data." + print(f"Resource content: {content}") + + async def _get_prompt(self): + """Get a prompt.""" + if not self.connected: + print("Not connected to server.") + return + + prompt_name = input("Enter prompt name: ").strip() + if not prompt_name: + print("Prompt name required.") + return + + print(f"Fetching prompt: {prompt_name}") + + # Mock prompt fetch + await asyncio.sleep(0.5) + prompt_text = f"Prompt template for {prompt_name}: Please analyze the following data..." + print(f"Prompt: {prompt_text}") + + async def _server_status(self): + """Show server status.""" + if not self.connected: + print("Not connected to server.") + return + + print("Server Status:") + print(f" Connected: {'Yes' if self.connected else 'No'}") + print(" Server: Example MCP Server v1.0.0") + print(" Protocol: 2025-06-18") + print(" Uptime: 2 hours") + +# Usage example +async def interactive_menu_example(): + """Run the interactive MCP client menu.""" + client = McpClientMenu() + await client.run() + +if __name__ == "__main__": + asyncio.run(interactive_menu_example()) +``` + +## Web interface utilities + +### HTML generation + +```python +""" +HTML generation utilities for web-based MCP clients. +""" + +from typing import Any, Dict, List +import json +import html + +class HtmlGenerator: + """Generate HTML for MCP client web interfaces.""" + + def __init__(self, theme: str = "light"): + self.theme = theme + self.styles = self._get_styles() + + def _get_styles(self) -> str: + """Get CSS styles for the theme.""" + if self.theme == "dark": + return """ + + """ + else: + return """ + + """ + + def generate_page(self, title: str, content: str) -> str: + """Generate complete HTML page.""" + return f""" + + + + + + {html.escape(title)} + {self.styles} + + +
+

{html.escape(title)}

+ {content} +
+ + + """ + + def format_server_info(self, server_info: dict) -> str: + """Format server information as HTML.""" + name = html.escape(server_info.get('name', 'Unknown')) + version = html.escape(server_info.get('version', 'Unknown')) + protocol = html.escape(server_info.get('protocolVersion', 'Unknown')) + + return f""" +
+

Server Information

+ + + + +
Name{name}
Version{version}
Protocol{protocol}
+
+ """ + + def format_tools_list(self, tools: list) -> str: + """Format tools list as HTML.""" + if not tools: + return '

No tools available

' + + rows = "" + for tool in tools: + name = html.escape(tool.get('name', '')) + description = html.escape(tool.get('description', 'No description')) + schema = self._format_schema_html(tool.get('inputSchema', {})) + + rows += f""" + + {name} + {description} + {schema} + + """ + + return f""" +
+

Available Tools

+ + + + + + + + + + {rows} + +
NameDescriptionParameters
+
+ """ + + def format_tool_result(self, tool_name: str, result: dict) -> str: + """Format tool result as HTML.""" + name = html.escape(tool_name) + success = result.get('success', True) + status_class = "success" if success else "error" + status_text = "✓ Success" if success else "✗ Error" + + content_html = "" + if 'content' in result: + for item in result['content']: + if isinstance(item, str): + content_html += f"

{html.escape(item)}

" + else: + content_html += f"
{html.escape(json.dumps(item, indent=2))}
" + + structured_html = "" + if 'structured' in result and result['structured']: + structured_html = f""" +

Structured Output:

+
{html.escape(json.dumps(result['structured'], indent=2))}
+ """ + + error_html = "" + if not success and 'error' in result: + error_html = f'

Error: {html.escape(result["error"])}

' + + return f""" +
+

Tool Result: {name} {status_text}

+ {content_html} + {structured_html} + {error_html} +
+ """ + + def _format_schema_html(self, schema: dict) -> str: + """Format input schema as HTML.""" + if not schema or 'properties' not in schema: + return "No parameters" + + props = schema['properties'] + required = schema.get('required', []) + + param_list = [] + for prop_name, prop_info in props.items(): + prop_type = prop_info.get('type', 'any') + is_required = prop_name in required + + param_text = f"{prop_name}: {prop_type}" + if is_required: + param_text = f"{param_text}" + + param_list.append(param_text) + + return ", ".join(param_list) + +# Usage example +def html_example(): + """Example of HTML generation.""" + generator = HtmlGenerator("light") + + # Sample data + server_info = { + "name": "Example MCP Server", + "version": "1.0.0", + "protocolVersion": "2025-06-18" + } + + tools = [ + { + "name": "calculate", + "description": "Perform mathematical calculations", + "inputSchema": { + "properties": { + "expression": {"type": "string"}, + "precision": {"type": "integer"} + }, + "required": ["expression"] + } + } + ] + + # Generate HTML components + server_html = generator.format_server_info(server_info) + tools_html = generator.format_tools_list(tools) + + # Combine into full page + content = server_html + tools_html + page = generator.generate_page("MCP Client Dashboard", content) + + # Save to file + with open("mcp_dashboard.html", "w") as f: + f.write(page) + + print("HTML dashboard saved to mcp_dashboard.html") + +if __name__ == "__main__": + html_example() +``` + +## Best practices + +### Design guidelines + +- **Consistent interface** - Use consistent styling and interaction patterns +- **Clear feedback** - Provide immediate feedback for all user actions +- **Error handling** - Display helpful error messages with recovery suggestions +- **Accessibility** - Support keyboard navigation and screen readers +- **Responsive design** - Work well on different screen sizes + +### Performance optimization + +- **Lazy loading** - Load visualization data only when needed +- **Caching** - Cache formatted output to avoid recomputation +- **Async operations** - Keep UI responsive during long operations +- **Memory management** - Clean up resources after use +- **Efficient rendering** - Minimize DOM updates and redraws + +### User experience + +- **Progressive disclosure** - Show basic info first, details on demand +- **Contextual help** - Provide help text and examples +- **Keyboard shortcuts** - Support common keyboard shortcuts +- **Search and filter** - Help users find relevant information +- **State persistence** - Remember user preferences and settings + +## Next steps + +- **[Parsing results](parsing-results.md)** - Advanced result processing +- **[OAuth for clients](oauth-clients.md)** - Authentication in client UIs +- **[Writing clients](writing-clients.md)** - Complete client development guide +- **[Low-level server](low-level-server.md)** - Server implementation details \ No newline at end of file diff --git a/docs/elicitation.md b/docs/elicitation.md new file mode 100644 index 000000000..2df7d7949 --- /dev/null +++ b/docs/elicitation.md @@ -0,0 +1,442 @@ +# Elicitation + +Elicitation allows servers to request additional information from users with structured validation. This enables interactive workflows where tools can gather missing data before proceeding. + +## Basic elicitation + +### Simple user input collection + +```python +from pydantic import BaseModel, Field +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + +mcp = FastMCP("Interactive Server") + +class UserInfo(BaseModel): + """Schema for collecting user information.""" + name: str = Field(description="Your full name") + email: str = Field(description="Your email address") + age: int = Field(description="Your age", ge=13, le=120) + +@mcp.tool() +async def collect_user_info(ctx: Context[ServerSession, None]) -> dict: + """Collect user information through elicitation.""" + result = await ctx.elicit( + message="Please provide your information to continue:", + schema=UserInfo + ) + + if result.action == "accept" and result.data: + return { + "status": "collected", + "name": result.data.name, + "email": result.data.email, + "age": result.data.age + } + elif result.action == "decline": + return {"status": "declined"} + else: + return {"status": "cancelled"} +``` + +### Conditional elicitation + +```python +class BookingPreferences(BaseModel): + """Schema for restaurant booking preferences.""" + alternative_date: str = Field(description="Alternative date (YYYY-MM-DD)") + party_size: int = Field(description="Number of people", ge=1, le=20) + dietary_restrictions: str = Field(default="", description="Any dietary restrictions") + +@mcp.tool() +async def book_restaurant( + restaurant: str, + preferred_date: str, + ctx: Context[ServerSession, None] +) -> dict: + """Book restaurant with fallback options.""" + + # Simulate availability check + if preferred_date in ["2024-12-25", "2024-12-31"]: # Busy dates + await ctx.warning(f"No availability at {restaurant} on {preferred_date}") + + result = await ctx.elicit( + message=f"Sorry, {restaurant} is fully booked on {preferred_date}. Would you like to try another date?", + schema=BookingPreferences + ) + + if result.action == "accept" and result.data: + booking = result.data + await ctx.info(f"Alternative booking confirmed for {booking.alternative_date}") + + return { + "status": "booked", + "restaurant": restaurant, + "date": booking.alternative_date, + "party_size": booking.party_size, + "dietary_restrictions": booking.dietary_restrictions, + "confirmation_id": f"BK{hash(booking.alternative_date) % 10000:04d}" + } + else: + return {"status": "cancelled", "reason": "No alternative date provided"} + else: + # Direct booking for available dates + return { + "status": "booked", + "restaurant": restaurant, + "date": preferred_date, + "confirmation_id": f"BK{hash(preferred_date) % 10000:04d}" + } +``` + +## Advanced elicitation patterns + +### Multi-step workflows + +```python +class ProjectDetails(BaseModel): + """Initial project information.""" + name: str = Field(description="Project name") + type: str = Field(description="Project type (web, mobile, desktop, api)") + timeline: str = Field(description="Expected timeline") + +class TechnicalRequirements(BaseModel): + """Technical requirements based on project type.""" + framework: str = Field(description="Preferred framework") + database: str = Field(description="Database type") + hosting: str = Field(description="Hosting preference") + team_size: int = Field(description="Team size", ge=1, le=50) + +@mcp.tool() +async def create_project_plan(ctx: Context[ServerSession, None]) -> dict: + """Create project plan through multi-step elicitation.""" + + # Step 1: Collect basic project details + await ctx.info("Starting project planning wizard...") + + project_result = await ctx.elicit( + message="Let's start by gathering basic project information:", + schema=ProjectDetails + ) + + if project_result.action != "accept" or not project_result.data: + return {"status": "cancelled", "step": "project_details"} + + project = project_result.data + await ctx.info(f"Project '{project.name}' details collected") + + # Step 2: Collect technical requirements + tech_result = await ctx.elicit( + message=f"Now let's configure technical requirements for your {project.type} project:", + schema=TechnicalRequirements + ) + + if tech_result.action != "accept" or not tech_result.data: + return { + "status": "partial", + "project_details": project.dict(), + "cancelled_at": "technical_requirements" + } + + tech = tech_result.data + await ctx.info("Technical requirements collected") + + # Generate project plan + plan = { + "project": { + "name": project.name, + "type": project.type, + "timeline": project.timeline + }, + "technical": { + "framework": tech.framework, + "database": tech.database, + "hosting": tech.hosting, + "team_size": tech.team_size + }, + "next_steps": [ + "Set up development environment", + "Create project repository", + "Define development workflow", + "Plan sprint structure" + ], + "status": "complete" + } + + await ctx.info(f"Project plan created for '{project.name}'") + return plan +``` + +### Dynamic schema generation + +```python +from typing import Any, Dict + +def create_survey_schema(questions: list[dict]) -> type[BaseModel]: + """Dynamically create a Pydantic model for survey questions.""" + fields = {} + + for i, question in enumerate(questions): + field_name = f"question_{i+1}" + field_type = str + + if question["type"] == "number": + field_type = int + elif question["type"] == "boolean": + field_type = bool + + fields[field_name] = (field_type, Field(description=question["text"])) + + return type("DynamicSurvey", (BaseModel,), {"__annotations__": {k: v[0] for k, v in fields.items()}, **{k: v[1] for k, v in fields.items()}}) + +@mcp.tool() +async def conduct_survey( + survey_title: str, + questions: list[dict], + ctx: Context[ServerSession, None] +) -> dict: + """Conduct dynamic survey using elicitation.""" + + if not questions: + raise ValueError("At least one question is required") + + # Create dynamic schema + SurveySchema = create_survey_schema(questions) + + await ctx.info(f"Starting survey: {survey_title}") + + result = await ctx.elicit( + message=f"Please complete this survey: {survey_title}", + schema=SurveySchema + ) + + if result.action == "accept" and result.data: + # Process responses + responses = {} + for i, question in enumerate(questions): + field_name = f"question_{i+1}" + responses[question["text"]] = getattr(result.data, field_name) + + await ctx.info(f"Survey completed with {len(responses)} responses") + + return { + "survey_title": survey_title, + "status": "completed", + "responses": responses, + "response_count": len(responses) + } + + return {"status": "not_completed", "reason": result.action} +``` + +## Error handling and validation + +### Robust elicitation with retries + +```python +class ContactInfo(BaseModel): + """Contact information with validation.""" + email: str = Field(description="Email address", regex=r'^[^@]+@[^@]+\.[^@]+$') + phone: str = Field(description="Phone number", regex=r'^[\d\s\-\(\)\+]+$') + preferred_contact: str = Field(description="Preferred contact method (email/phone)") + +@mcp.tool() +async def collect_contact_info( + ctx: Context[ServerSession, None], + max_attempts: int = 3 +) -> dict: + """Collect contact info with validation and retries.""" + + for attempt in range(max_attempts): + await ctx.info(f"Contact info collection attempt {attempt + 1}/{max_attempts}") + + result = await ctx.elicit( + message="Please provide your contact information:", + schema=ContactInfo + ) + + if result.action == "accept" and result.data: + # Additional validation + contact = result.data + + if contact.preferred_contact not in ["email", "phone"]: + if attempt < max_attempts - 1: + await ctx.warning("Invalid preferred contact method. Please choose 'email' or 'phone'.") + continue + else: + return { + "status": "error", + "error": "Invalid preferred contact method after max attempts" + } + + await ctx.info("Contact information validated successfully") + + return { + "status": "success", + "contact_info": { + "email": contact.email, + "phone": contact.phone, + "preferred_contact": contact.preferred_contact + }, + "attempts_used": attempt + 1 + } + + elif result.action == "decline": + return {"status": "declined", "attempts_used": attempt + 1} + + else: # cancelled + if attempt < max_attempts - 1: + await ctx.info("Input cancelled, retrying...") + else: + return {"status": "cancelled", "attempts_used": max_attempts} + + return {"status": "max_attempts_exceeded", "attempts_used": max_attempts} +``` + +### Validation error handling + +```python +from pydantic import ValidationError + +class OrderInfo(BaseModel): + """Order information with strict validation.""" + item_id: str = Field(description="Product ID", min_length=3, max_length=10) + quantity: int = Field(description="Quantity to order", ge=1, le=100) + shipping_address: str = Field(description="Shipping address", min_length=10) + express_shipping: bool = Field(description="Express shipping?", default=False) + +@mcp.tool() +async def process_order(ctx: Context[ServerSession, None]) -> dict: + """Process order with detailed validation feedback.""" + + while True: # Continue until valid or cancelled + result = await ctx.elicit( + message="Please provide order details:", + schema=OrderInfo + ) + + if result.action == "accept": + if result.data: + order = result.data + + # Additional business logic validation + validation_errors = [] + + # Check if item exists (simulated) + valid_items = ["ITEM001", "ITEM002", "ITEM003"] + if order.item_id not in valid_items: + validation_errors.append(f"Item ID '{order.item_id}' not found") + + # Check quantity limits based on item (simulated) + if order.item_id == "ITEM001" and order.quantity > 10: + validation_errors.append("Maximum 10 units allowed for ITEM001") + + if validation_errors: + error_message = "Validation errors found:\n" + "\n".join(f"- {error}" for error in validation_errors) + await ctx.warning(error_message) + await ctx.info("Please correct the errors and try again") + continue # Retry elicitation + + # Process successful order + await ctx.info(f"Order processed for item {order.item_id}") + + return { + "status": "processed", + "order_id": f"ORD{hash(order.item_id + str(order.quantity)) % 10000:04d}", + "item_id": order.item_id, + "quantity": order.quantity, + "express_shipping": order.express_shipping, + "estimated_delivery": "3-5 days" if not order.express_shipping else "1-2 days" + } + else: + await ctx.warning("No order data received") + continue + + elif result.action == "decline": + return {"status": "declined"} + + else: # cancelled + return {"status": "cancelled"} +``` + +## Testing elicitation + +### Unit testing with mocks + +```python +import pytest +from unittest.mock import Mock, AsyncMock +from mcp.types import ElicitationResult + +@pytest.mark.asyncio +async def test_elicitation_accept(): + """Test successful elicitation.""" + + # Mock elicitation result + mock_data = UserInfo(name="Test User", email="test@example.com", age=25) + mock_result = ElicitationResult( + action="accept", + data=mock_data, + validation_error=None + ) + + # Mock context + mock_ctx = Mock() + mock_ctx.elicit = AsyncMock(return_value=mock_result) + + # Test function + result = await collect_user_info(mock_ctx) + + assert result["status"] == "collected" + assert result["name"] == "Test User" + assert result["email"] == "test@example.com" + mock_ctx.elicit.assert_called_once() + +@pytest.mark.asyncio +async def test_elicitation_decline(): + """Test declined elicitation.""" + + mock_result = ElicitationResult( + action="decline", + data=None, + validation_error=None + ) + + mock_ctx = Mock() + mock_ctx.elicit = AsyncMock(return_value=mock_result) + + result = await collect_user_info(mock_ctx) + + assert result["status"] == "declined" +``` + +## Best practices + +### Design guidelines + +- **Clear messaging** - Provide clear, specific instructions in elicitation messages +- **Progressive complexity** - Start with simple requests, build up complexity +- **Graceful degradation** - Handle cancellation and errors appropriately +- **Validation feedback** - Give users clear feedback on validation errors + +### User experience + +- **Reasonable defaults** - Provide sensible default values where appropriate +- **Context awareness** - Reference previous inputs in multi-step workflows +- **Progress indication** - Show users where they are in multi-step processes +- **Escape routes** - Always provide ways to cancel or go back + +### Performance considerations + +- **Timeout handling** - Set reasonable timeouts for user input +- **State management** - Clean up incomplete elicitation state +- **Error recovery** - Implement retry logic for network issues +- **Resource cleanup** - Free resources for abandoned elicitations + +## Next steps + +- **[Sampling integration](sampling.md)** - Use elicitation with LLM sampling +- **[Progress reporting](progress-logging.md)** - Show progress during elicitation +- **[Context patterns](context.md)** - Advanced context usage in elicitation +- **[Authentication](authentication.md)** - Securing elicitation endpoints \ No newline at end of file diff --git a/docs/images.md b/docs/images.md new file mode 100644 index 000000000..5129f96a4 --- /dev/null +++ b/docs/images.md @@ -0,0 +1,780 @@ +# Images + +The MCP Python SDK provides comprehensive support for working with image data in tools and resources. The `Image` class handles image processing, validation, and format conversion automatically. + +## Image basics + +### The Image class + +The `Image` class automatically handles image data and provides convenient methods for common operations: + +```python +from mcp.server.fastmcp import FastMCP, Image + +mcp = FastMCP("Image Processing Server") + +@mcp.tool() +def create_simple_image() -> Image: + """Create a simple colored image.""" + from PIL import Image as PILImage + import io + + # Create a simple red square + img = PILImage.new('RGB', (100, 100), color='red') + + # Convert to bytes + img_bytes = io.BytesIO() + img.save(img_bytes, format='PNG') + img_bytes.seek(0) + + return Image(data=img_bytes.getvalue(), format="png") +``` + +### Working with PIL (Pillow) + +The most common pattern is using PIL/Pillow for image operations: + +```python +from PIL import Image as PILImage, ImageDraw, ImageFont +import io + +@mcp.tool() +def create_text_image(text: str, width: int = 400, height: int = 200) -> Image: + """Create an image with text.""" + # Create a white background + img = PILImage.new('RGB', (width, height), color='white') + draw = ImageDraw.Draw(img) + + # Try to use a default font, fall back to PIL default + try: + font = ImageFont.truetype("arial.ttf", 24) + except OSError: + font = ImageFont.load_default() + + # Calculate text position (centered) + bbox = draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + x = (width - text_width) // 2 + y = (height - text_height) // 2 + + # Draw text + draw.text((x, y), text, fill='black', font=font) + + # Convert to bytes + img_buffer = io.BytesIO() + img.save(img_buffer, format='PNG') + img_buffer.seek(0) + + return Image(data=img_buffer.getvalue(), format="png") + +@mcp.tool() +def create_thumbnail(image_data: bytes, size: tuple[int, int] = (128, 128)) -> Image: + """Create a thumbnail from image data.""" + # Load image from bytes + img_buffer = io.BytesIO(image_data) + img = PILImage.open(img_buffer) + + # Create thumbnail (maintains aspect ratio) + img.thumbnail(size, PILImage.Resampling.LANCZOS) + + # Convert back to bytes + output_buffer = io.BytesIO() + img.save(output_buffer, format='PNG') + output_buffer.seek(0) + + return Image(data=output_buffer.getvalue(), format="png") +``` + +## Image processing tools + +### Basic image operations + +```python +from PIL import Image as PILImage, ImageFilter, ImageEnhance +import io + +@mcp.tool() +def apply_blur(image_data: bytes, radius: float = 2.0) -> Image: + """Apply Gaussian blur to an image.""" + # Load image + img = PILImage.open(io.BytesIO(image_data)) + + # Apply blur filter + blurred = img.filter(ImageFilter.GaussianBlur(radius=radius)) + + # Convert to bytes + output = io.BytesIO() + blurred.save(output, format='PNG') + output.seek(0) + + return Image(data=output.getvalue(), format="png") + +@mcp.tool() +def adjust_brightness(image_data: bytes, factor: float = 1.5) -> Image: + """Adjust image brightness.""" + if not 0.1 <= factor <= 3.0: + raise ValueError("Brightness factor must be between 0.1 and 3.0") + + img = PILImage.open(io.BytesIO(image_data)) + + # Adjust brightness + enhancer = ImageEnhance.Brightness(img) + brightened = enhancer.enhance(factor) + + output = io.BytesIO() + brightened.save(output, format='PNG') + output.seek(0) + + return Image(data=output.getvalue(), format="png") + +@mcp.tool() +def resize_image( + image_data: bytes, + width: int, + height: int, + maintain_aspect: bool = True +) -> Image: + """Resize an image to specified dimensions.""" + img = PILImage.open(io.BytesIO(image_data)) + + if maintain_aspect: + # Calculate size maintaining aspect ratio + img.thumbnail((width, height), PILImage.Resampling.LANCZOS) + resized = img + else: + # Force exact dimensions + resized = img.resize((width, height), PILImage.Resampling.LANCZOS) + + output = io.BytesIO() + resized.save(output, format='PNG') + output.seek(0) + + return Image(data=output.getvalue(), format="png") + +@mcp.tool() +def convert_format(image_data: bytes, target_format: str) -> Image: + """Convert image to different format.""" + supported_formats = ['PNG', 'JPEG', 'WEBP', 'GIF', 'BMP'] + target_format = target_format.upper() + + if target_format not in supported_formats: + raise ValueError(f"Unsupported format. Use one of: {supported_formats}") + + img = PILImage.open(io.BytesIO(image_data)) + + # Handle JPEG (no alpha channel) + if target_format == 'JPEG' and img.mode in ('RGBA', 'LA', 'P'): + # Convert to RGB (white background) + background = PILImage.new('RGB', img.size, 'white') + if img.mode == 'P': + img = img.convert('RGBA') + background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) + img = background + + output = io.BytesIO() + img.save(output, format=target_format) + output.seek(0) + + return Image(data=output.getvalue(), format=target_format.lower()) +``` + +### Advanced image operations + +```python +from PIL import Image as PILImage, ImageOps +import io + +@mcp.tool() +def create_collage(images: list[bytes], grid_size: tuple[int, int] = (2, 2)) -> Image: + """Create a collage from multiple images.""" + if len(images) > grid_size[0] * grid_size[1]: + raise ValueError(f"Too many images for {grid_size[0]}x{grid_size[1]} grid") + + if not images: + raise ValueError("At least one image is required") + + # Load all images + pil_images = [PILImage.open(io.BytesIO(img_data)) for img_data in images] + + # Calculate cell size (use first image as reference) + cell_width = pil_images[0].width + cell_height = pil_images[0].height + + # Resize all images to match the first one + resized_images = [] + for img in pil_images: + resized = img.resize((cell_width, cell_height), PILImage.Resampling.LANCZOS) + resized_images.append(resized) + + # Create collage canvas + canvas_width = cell_width * grid_size[0] + canvas_height = cell_height * grid_size[1] + collage = PILImage.new('RGB', (canvas_width, canvas_height), 'white') + + # Paste images into grid + for idx, img in enumerate(resized_images): + row = idx // grid_size[0] + col = idx % grid_size[0] + x = col * cell_width + y = row * cell_height + collage.paste(img, (x, y)) + + # Convert to bytes + output = io.BytesIO() + collage.save(output, format='PNG') + output.seek(0) + + return Image(data=output.getvalue(), format="png") + +@mcp.tool() +def add_border( + image_data: bytes, + border_width: int = 10, + border_color: str = "black" +) -> Image: + """Add a border around an image.""" + img = PILImage.open(io.BytesIO(image_data)) + + # Add border + bordered = ImageOps.expand(img, border=border_width, fill=border_color) + + output = io.BytesIO() + bordered.save(output, format='PNG') + output.seek(0) + + return Image(data=output.getvalue(), format="png") + +@mcp.tool() +def apply_filters(image_data: bytes, filter_name: str) -> Image: + """Apply various filters to an image.""" + img = PILImage.open(io.BytesIO(image_data)) + + filters = { + "blur": ImageFilter.BLUR, + "contour": ImageFilter.CONTOUR, + "detail": ImageFilter.DETAIL, + "edge_enhance": ImageFilter.EDGE_ENHANCE, + "emboss": ImageFilter.EMBOSS, + "find_edges": ImageFilter.FIND_EDGES, + "sharpen": ImageFilter.SHARPEN, + "smooth": ImageFilter.SMOOTH + } + + if filter_name not in filters: + raise ValueError(f"Unknown filter. Available: {list(filters.keys())}") + + filtered = img.filter(filters[filter_name]) + + output = io.BytesIO() + filtered.save(output, format='PNG') + output.seek(0) + + return Image(data=output.getvalue(), format="png") +``` + +## Chart and visualization generation + +### Creating charts with matplotlib + +```python +import matplotlib.pyplot as plt +import matplotlib +import io +import numpy as np + +# Use non-interactive backend +matplotlib.use('Agg') + +@mcp.tool() +def create_line_chart( + data: list[float], + labels: list[str] | None = None, + title: str = "Line Chart" +) -> Image: + """Create a line chart from data.""" + plt.figure(figsize=(10, 6)) + + x_values = labels if labels else list(range(len(data))) + plt.plot(x_values, data, marker='o', linewidth=2, markersize=6) + + plt.title(title, fontsize=16) + plt.xlabel("X Axis") + plt.ylabel("Y Axis") + plt.grid(True, alpha=0.3) + + # Rotate x-axis labels if they're strings + if labels and isinstance(labels[0], str): + plt.xticks(rotation=45) + + plt.tight_layout() + + # Save to bytes + img_buffer = io.BytesIO() + plt.savefig(img_buffer, format='PNG', dpi=150, bbox_inches='tight') + img_buffer.seek(0) + plt.close() + + return Image(data=img_buffer.getvalue(), format="png") + +@mcp.tool() +def create_bar_chart( + values: list[float], + categories: list[str], + title: str = "Bar Chart", + color: str = "steelblue" +) -> Image: + """Create a bar chart.""" + if len(values) != len(categories): + raise ValueError("Values and categories must have the same length") + + plt.figure(figsize=(10, 6)) + bars = plt.bar(categories, values, color=color, alpha=0.8) + + plt.title(title, fontsize=16) + plt.xlabel("Categories") + plt.ylabel("Values") + + # Add value labels on bars + for bar, value in zip(bars, values): + plt.text(bar.get_x() + bar.get_width()/2, bar.get_height() + max(values)*0.01, + f'{value:.1f}', ha='center', va='bottom') + + plt.xticks(rotation=45) + plt.grid(axis='y', alpha=0.3) + plt.tight_layout() + + img_buffer = io.BytesIO() + plt.savefig(img_buffer, format='PNG', dpi=150, bbox_inches='tight') + img_buffer.seek(0) + plt.close() + + return Image(data=img_buffer.getvalue(), format="png") + +@mcp.tool() +def create_pie_chart( + values: list[float], + labels: list[str], + title: str = "Pie Chart" +) -> Image: + """Create a pie chart.""" + if len(values) != len(labels): + raise ValueError("Values and labels must have the same length") + + plt.figure(figsize=(8, 8)) + + # Create pie chart with percentages + wedges, texts, autotexts = plt.pie( + values, + labels=labels, + autopct='%1.1f%%', + startangle=90, + colors=plt.cm.Set3.colors + ) + + plt.title(title, fontsize=16) + + # Equal aspect ratio ensures pie is drawn as circle + plt.axis('equal') + + img_buffer = io.BytesIO() + plt.savefig(img_buffer, format='PNG', dpi=150, bbox_inches='tight') + img_buffer.seek(0) + plt.close() + + return Image(data=img_buffer.getvalue(), format="png") + +@mcp.tool() +def create_scatter_plot( + x_data: list[float], + y_data: list[float], + title: str = "Scatter Plot", + x_label: str = "X Axis", + y_label: str = "Y Axis" +) -> Image: + """Create a scatter plot.""" + if len(x_data) != len(y_data): + raise ValueError("X and Y data must have the same length") + + plt.figure(figsize=(10, 6)) + plt.scatter(x_data, y_data, alpha=0.6, s=50, color='steelblue') + + plt.title(title, fontsize=16) + plt.xlabel(x_label) + plt.ylabel(y_label) + plt.grid(True, alpha=0.3) + + plt.tight_layout() + + img_buffer = io.BytesIO() + plt.savefig(img_buffer, format='PNG', dpi=150, bbox_inches='tight') + img_buffer.seek(0) + plt.close() + + return Image(data=img_buffer.getvalue(), format="png") +``` + +## Image analysis tools + +### Image information extraction + +```python +from PIL import Image as PILImage, ExifTags +from PIL.ExifTags import TAGS +import io + +@mcp.tool() +def analyze_image(image_data: bytes) -> dict: + """Analyze an image and extract information.""" + img = PILImage.open(io.BytesIO(image_data)) + + analysis = { + "format": img.format, + "mode": img.mode, + "size": { + "width": img.width, + "height": img.height + }, + "aspect_ratio": round(img.width / img.height, 2), + "has_transparency": img.mode in ('RGBA', 'LA') or 'transparency' in img.info + } + + # Calculate file size + analysis["file_size_bytes"] = len(image_data) + analysis["file_size_kb"] = round(len(image_data) / 1024, 2) + + # Extract color information + if img.mode == 'RGB': + # Sample dominant colors (simplified) + colors = img.getcolors(maxcolors=256*256*256) + if colors: + # Get most common color + most_common = max(colors, key=lambda x: x[0]) + analysis["dominant_color"] = { + "rgb": most_common[1], + "pixel_count": most_common[0] + } + + # Try to extract EXIF data + try: + exifdata = img.getexif() + if exifdata: + exif_info = {} + for tag_id in exifdata: + tag = TAGS.get(tag_id, tag_id) + data = exifdata.get(tag_id) + # Only include readable string/numeric data + if isinstance(data, (str, int, float)): + exif_info[tag] = data + analysis["exif"] = exif_info + except: + analysis["exif"] = None + + return analysis + +@mcp.tool() +def get_color_palette(image_data: bytes, num_colors: int = 5) -> dict: + """Extract a color palette from an image.""" + img = PILImage.open(io.BytesIO(image_data)) + + # Convert to RGB if necessary + if img.mode != 'RGB': + img = img.convert('RGB') + + # Resize image for faster processing + img = img.resize((150, 150), PILImage.Resampling.LANCZOS) + + # Get colors using PIL's quantize + quantized = img.quantize(colors=num_colors) + palette_colors = quantized.getpalette() + + # Extract RGB tuples + colors = [] + for i in range(num_colors): + r = palette_colors[i * 3] + g = palette_colors[i * 3 + 1] + b = palette_colors[i * 3 + 2] + + # Convert to hex + hex_color = f"#{r:02x}{g:02x}{b:02x}" + + colors.append({ + "rgb": [r, g, b], + "hex": hex_color + }) + + return { + "palette": colors, + "num_colors": len(colors) + } +``` + +## Resource-based image serving + +### Image resources + +```python +import os +from pathlib import Path + +# Define allowed image directory +IMAGE_DIR = Path("/safe/images") + +@mcp.resource("image://{filename}") +def get_image(filename: str) -> str: + """Get image data as base64 encoded string.""" + import base64 + + # Security: validate filename + if ".." in filename or "/" in filename: + raise ValueError("Invalid filename") + + image_path = IMAGE_DIR / filename + + if not image_path.exists(): + raise ValueError(f"Image {filename} not found") + + # Read image file + try: + image_data = image_path.read_bytes() + + # Determine MIME type based on extension + ext = image_path.suffix.lower() + mime_types = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp' + } + mime_type = mime_types.get(ext, 'application/octet-stream') + + # Encode as base64 + encoded_data = base64.b64encode(image_data).decode('utf-8') + + return f"data:{mime_type};base64,{encoded_data}" + + except Exception as e: + raise ValueError(f"Cannot read image {filename}: {e}") + +@mcp.resource("images://list") +def list_images() -> str: + """List all available images.""" + try: + image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'} + images = [ + f.name for f in IMAGE_DIR.iterdir() + if f.is_file() and f.suffix.lower() in image_extensions + ] + + if not images: + return "No images available" + + result = "Available images:\\n" + for img in sorted(images): + img_path = IMAGE_DIR / img + size = img_path.stat().st_size + result += f"- {img} ({size} bytes)\\n" + + return result + + except Exception as e: + return f"Cannot list images: {e}" +``` + +## Error handling and validation + +### Image validation + +```python +def validate_image_data(image_data: bytes) -> bool: + """Validate that data is a valid image.""" + try: + img = PILImage.open(io.BytesIO(image_data)) + img.verify() # Check if image is corrupted + return True + except Exception: + return False + +@mcp.tool() +def safe_image_operation(image_data: bytes, operation: str) -> Image: + """Perform image operations with validation.""" + # Validate input + if not image_data: + raise ValueError("No image data provided") + + if not validate_image_data(image_data): + raise ValueError("Invalid or corrupted image data") + + # Check file size (limit to 10MB) + max_size = 10 * 1024 * 1024 # 10MB + if len(image_data) > max_size: + raise ValueError(f"Image too large: {len(image_data)} bytes (max: {max_size})") + + img = PILImage.open(io.BytesIO(image_data)) + + # Check image dimensions + max_dimension = 4000 + if img.width > max_dimension or img.height > max_dimension: + raise ValueError(f"Image dimensions too large: {img.width}x{img.height} (max: {max_dimension})") + + # Perform operation + if operation == "normalize": + # Convert to standard RGB format + if img.mode != 'RGB': + img = img.convert('RGB') + elif operation == "thumbnail": + img.thumbnail((256, 256), PILImage.Resampling.LANCZOS) + else: + raise ValueError(f"Unknown operation: {operation}") + + # Convert back to bytes + output = io.BytesIO() + img.save(output, format='PNG') + output.seek(0) + + return Image(data=output.getvalue(), format="png") +``` + +## Testing image tools + +### Unit testing with mock images + +```python +import pytest +from PIL import Image as PILImage +import io + +def create_test_image(width: int = 100, height: int = 100, color: str = 'red') -> bytes: + """Create a test image for unit testing.""" + img = PILImage.new('RGB', (width, height), color=color) + buffer = io.BytesIO() + img.save(buffer, format='PNG') + buffer.seek(0) + return buffer.getvalue() + +def test_image_resize(): + """Test image resizing functionality.""" + # Create test image + test_data = create_test_image(200, 200, 'blue') + + # Test resize function + mcp = FastMCP("Test") + + @mcp.tool() + def resize_test(image_data: bytes, width: int, height: int) -> Image: + img = PILImage.open(io.BytesIO(image_data)) + resized = img.resize((width, height), PILImage.Resampling.LANCZOS) + output = io.BytesIO() + resized.save(output, format='PNG') + output.seek(0) + return Image(data=output.getvalue(), format="png") + + result = resize_test(test_data, 50, 50) + + # Verify result + assert isinstance(result, Image) + assert result.format == "png" + + # Verify dimensions + result_img = PILImage.open(io.BytesIO(result.data)) + assert result_img.size == (50, 50) + +def test_image_analysis(): + """Test image analysis functionality.""" + test_data = create_test_image(300, 200, 'green') + + analysis = analyze_image(test_data) + + assert analysis["size"]["width"] == 300 + assert analysis["size"]["height"] == 200 + assert analysis["format"] == "PNG" + assert analysis["aspect_ratio"] == 1.5 +``` + +## Performance optimization + +### Image processing optimization + +```python +from concurrent.futures import ThreadPoolExecutor +import asyncio + +@mcp.tool() +async def batch_process_images( + images: list[bytes], + operation: str, + ctx: Context +) -> list[Image]: + """Process multiple images efficiently.""" + await ctx.info(f"Processing {len(images)} images with operation: {operation}") + + def process_single_image(img_data: bytes) -> Image: + """Process a single image (runs in thread pool).""" + img = PILImage.open(io.BytesIO(img_data)) + + if operation == "thumbnail": + img.thumbnail((128, 128), PILImage.Resampling.LANCZOS) + elif operation == "grayscale": + img = img.convert('L') + + output = io.BytesIO() + img.save(output, format='PNG') + output.seek(0) + + return Image(data=output.getvalue(), format="png") + + # Process images in parallel using thread pool + loop = asyncio.get_event_loop() + with ThreadPoolExecutor(max_workers=4) as executor: + tasks = [ + loop.run_in_executor(executor, process_single_image, img_data) + for img_data in images + ] + + results = [] + for i, task in enumerate(asyncio.as_completed(tasks)): + result = await task + results.append(result) + + # Report progress + progress = (i + 1) / len(images) + await ctx.report_progress( + progress=progress, + message=f"Processed {i + 1}/{len(images)} images" + ) + + await ctx.info("Batch processing completed") + return results +``` + +## Best practices + +### Image handling guidelines + +- **Validate inputs** - Always verify image data before processing +- **Limit sizes** - Set reasonable limits on image dimensions and file sizes +- **Use appropriate formats** - Choose the right format for the use case +- **Handle errors gracefully** - Provide clear error messages for invalid images +- **Optimize performance** - Use threading for batch operations + +### Memory management + +- **Process in batches** - Don't load too many large images at once +- **Close PIL images** - Let PIL handle garbage collection +- **Use BytesIO efficiently** - Reuse buffers when possible +- **Monitor memory usage** - Be aware of memory consumption for large images + +### Security considerations + +- **Validate image formats** - Only allow expected image types +- **Limit processing time** - Set timeouts for complex operations +- **Sanitize filenames** - Prevent path traversal attacks +- **Check file sizes** - Prevent denial of service through large uploads + +## Next steps + +- **[Advanced tools](tools.md)** - Building complex image processing workflows +- **[Context usage](context.md)** - Progress reporting for long image operations +- **[Resource patterns](resources.md)** - Serving images through resources +- **[Authentication](authentication.md)** - Securing image processing endpoints \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index aefa2a6ab..4b5697998 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,3 +1,82 @@ -# MCP Server +# MCP Python SDK -This is the MCP Server implementation in Python. +A Python implementation of the Model Context Protocol (MCP) that enables applications to provide context for LLMs in a standardized way. + +## Overview + +The Model Context Protocol allows you to build servers that expose data and functionality to LLM applications securely. This Python SDK implements the full MCP specification with both high-level FastMCP and low-level server implementations. + +### Key features + +- **FastMCP server framework** - High-level, decorator-based server creation +- **Multiple transports** - stdio, SSE, and Streamable HTTP support +- **Type-safe development** - Full type hints and Pydantic integration +- **Authentication support** - OAuth 2.1 resource server capabilities +- **Rich tooling** - Built-in development and deployment utilities + +## Quick start + +Create a simple MCP server in minutes: + +```python +from mcp.server.fastmcp import FastMCP + +# Create server +mcp = FastMCP("Demo") + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers""" + return a + b + +@mcp.resource("greeting://{name}") +def get_greeting(name: str) -> str: + """Get a personalized greeting""" + return f"Hello, {name}!" +``` + +Install in Claude Desktop: + +```bash +uv run mcp install server.py +``` + +## Documentation sections + +### Getting started +- **[Quickstart](quickstart.md)** - Build your first MCP server +- **[Installation](installation.md)** - Setup and dependencies + +### Core concepts +- **[Servers](servers.md)** - Server creation and lifecycle management +- **[Resources](resources.md)** - Exposing data to LLMs +- **[Tools](tools.md)** - Creating LLM-callable functions +- **[Prompts](prompts.md)** - Reusable interaction templates +- **[Context](context.md)** - Request context and capabilities + +### Advanced features +- **[Images](images.md)** - Working with image data +- **[Authentication](authentication.md)** - OAuth 2.1 implementation +- **[Sampling](sampling.md)** - LLM text generation +- **[Elicitation](elicitation.md)** - User input collection +- **[Progress & logging](progress-logging.md)** - Status updates and notifications + +### Transport & deployment +- **[Running servers](running-servers.md)** - Development and production deployment +- **[Streamable HTTP](streamable-http.md)** - Modern HTTP transport +- **[ASGI integration](asgi-integration.md)** - Mounting to existing web servers + +### Client development +- **[Writing clients](writing-clients.md)** - MCP client implementation +- **[OAuth for clients](oauth-clients.md)** - Client-side authentication +- **[Display utilities](display-utilities.md)** - UI helper functions +- **[Parsing results](parsing-results.md)** - Handling tool responses + +### Advanced usage +- **[Low-level server](low-level-server.md)** - Direct protocol implementation +- **[Structured output](structured-output.md)** - Advanced type patterns +- **[Completions](completions.md)** - Argument completion system + +## API reference + +Complete API documentation is auto-generated from the source code and available in the [API Reference](reference/) section. diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 000000000..82f261ce8 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,194 @@ +# Installation + +Learn how to install and set up the MCP Python SDK for different use cases. + +## Prerequisites + +- **Python 3.10 or later** +- **uv package manager** (recommended) or pip + +### Installing uv + +If you don't have uv installed: + +```bash +# macOS and Linux +curl -LsSf https://astral.sh/uv/install.sh | sh + +# Windows +powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" + +# Or with pip +pip install uv +``` + +## Installation methods + +### For new projects (recommended) + +Create a new uv-managed project: + +```bash +uv init my-mcp-server +cd my-mcp-server +uv add "mcp[cli]" +``` + +This creates a complete project structure with: +- `pyproject.toml` - Project configuration +- `src/` directory for your code +- Virtual environment management + +### Add to existing project + +If you have an existing project: + +```bash +uv add "mcp[cli]" +``` + +### Using pip + +For projects that use pip: + +```bash +pip install "mcp[cli]" +``` + +## Package variants + +The MCP SDK offers different installation options: + +### Core package +```bash +uv add mcp +``` + +Includes: +- Core MCP protocol implementation +- FastMCP server framework +- Client libraries +- All transport types (stdio, SSE, Streamable HTTP) + +### CLI tools +```bash +uv add "mcp[cli]" +``` + +Adds CLI utilities for: +- `mcp dev` - Development server with web inspector +- `mcp install` - Claude Desktop integration +- `mcp run` - Direct server execution + +### Rich output +```bash +uv add "mcp[rich]" +``` + +Adds enhanced terminal output with colors and formatting. + +### WebSocket support +```bash +uv add "mcp[ws]" +``` + +Adds WebSocket transport capabilities. + +### All features +```bash +uv add "mcp[cli,rich,ws]" +``` + +## Development setup + +For contributing to the MCP SDK or advanced development: + +```bash +git clone https://github.com/modelcontextprotocol/python-sdk +cd python-sdk +uv sync --group docs --group dev +``` + +This installs: +- All dependencies +- Development tools (ruff, pyright, pytest) +- Documentation tools (mkdocs, mkdocs-material) + +## Verify installation + +Test your installation: + +```bash +# Check MCP CLI is available +uv run mcp --help + +# Create and test a simple server +echo 'from mcp.server.fastmcp import FastMCP +mcp = FastMCP("Test") +@mcp.tool() +def hello() -> str: + return "Hello from MCP!" +if __name__ == "__main__": + mcp.run()' > test_server.py + +# Test the server +uv run mcp dev test_server.py +``` + +If successful, you'll see the MCP Inspector web interface open. + +## IDE integration + +### VS Code + +For the best development experience, install: + +- **Python extension** - Python language support +- **Pylance** - Advanced Python features +- **Ruff** - Code formatting and linting + +### Type checking + +The MCP SDK includes comprehensive type hints. Enable strict type checking: + +```bash +# Check types +uv run pyright + +# In VS Code, add to settings.json: +{ + "python.analysis.typeCheckingMode": "strict" +} +``` + +## Troubleshooting + +### Common issues + +**"mcp command not found"** +- Ensure uv is in your PATH +- Try `uv run mcp` instead of just `mcp` + +**Import errors** +- Verify installation: `uv run python -c "import mcp; print(mcp.__version__)"` +- Check you're in the right directory/virtual environment + +**Permission errors on Windows** +- Run terminal as administrator for global installations +- Use `--user` flag with pip if needed + +**Python version conflicts** +- Check version: `python --version` +- Use specific Python: `uv python install 3.11` then `uv python use 3.11` + +### Getting help + +- **GitHub Issues**: [Report bugs and feature requests](https://github.com/modelcontextprotocol/python-sdk/issues) +- **Discussions**: [Community support](https://github.com/modelcontextprotocol/python-sdk/discussions) +- **Documentation**: [Official MCP docs](https://modelcontextprotocol.io) + +## Next steps + +- **[Build your first server](quickstart.md)** - Follow the quickstart guide +- **[Learn core concepts](servers.md)** - Understand MCP fundamentals +- **[Explore examples](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples)** - See real-world implementations \ No newline at end of file diff --git a/docs/low-level-server.md b/docs/low-level-server.md new file mode 100644 index 000000000..e267e7eea --- /dev/null +++ b/docs/low-level-server.md @@ -0,0 +1,1299 @@ +# Low-level server + +Learn how to build MCP servers using the low-level protocol implementation for maximum control and customization. + +## Overview + +Low-level server development provides: + +- **Protocol control** - Direct access to MCP protocol messages +- **Custom transports** - Implement custom transport mechanisms +- **Advanced error handling** - Fine-grained error control and reporting +- **Performance optimization** - Optimize for specific use cases +- **Protocol extensions** - Add custom protocol features + +## Basic low-level server + +### Core server implementation + +```python +""" +Low-level MCP server implementation. +""" + +import asyncio +import json +import logging +from typing import Any, Dict, List, Optional, Callable, Awaitable +from dataclasses import dataclass +from contextlib import asynccontextmanager +from collections.abc import AsyncIterator + +from mcp.types import ( + JSONRPCMessage, JSONRPCRequest, JSONRPCResponse, JSONRPCError, + InitializeRequest, InitializeResult, ServerInfo, + ListToolsRequest, ListToolsResult, Tool, + CallToolRequest, CallToolResult, TextContent, + ListResourcesRequest, ListResourcesResult, Resource, + ReadResourceRequest, ReadResourceResult, + ListPromptsRequest, ListPromptsResult, Prompt, + GetPromptRequest, GetPromptResult, PromptMessage +) +from mcp.server import Server +from mcp.server.stdio import stdio_server +from mcp.server.session import ServerSession + +logger = logging.getLogger(__name__) + +class LowLevelServer: + """Low-level MCP server with direct protocol access.""" + + def __init__(self, name: str, version: str = "1.0.0"): + self.name = name + self.version = version + + # Protocol handlers + self.request_handlers: Dict[str, Callable] = {} + self.notification_handlers: Dict[str, Callable] = {} + + # Server state + self.initialized = False + self.capabilities = {} + self.client_capabilities = {} + + # Register core handlers + self._register_core_handlers() + + # Custom tool, resource, and prompt registries + self.tools: Dict[str, Callable] = {} + self.resources: Dict[str, Callable] = {} + self.prompts: Dict[str, Callable] = {} + + def _register_core_handlers(self): + """Register core MCP protocol handlers.""" + self.request_handlers.update({ + "initialize": self._handle_initialize, + "tools/list": self._handle_list_tools, + "tools/call": self._handle_call_tool, + "resources/list": self._handle_list_resources, + "resources/read": self._handle_read_resource, + "prompts/list": self._handle_list_prompts, + "prompts/get": self._handle_get_prompt, + }) + + self.notification_handlers.update({ + "initialized": self._handle_initialized, + "progress": self._handle_progress, + }) + + def register_tool(self, name: str, handler: Callable, description: str = "", input_schema: Dict[str, Any] = None): + """Register a tool handler.""" + self.tools[name] = { + 'handler': handler, + 'description': description, + 'input_schema': input_schema or {} + } + + def register_resource(self, uri: str, handler: Callable, name: str = "", description: str = ""): + """Register a resource handler.""" + self.resources[uri] = { + 'handler': handler, + 'name': name or uri, + 'description': description + } + + def register_prompt(self, name: str, handler: Callable, description: str = "", arguments: List[Dict[str, Any]] = None): + """Register a prompt handler.""" + self.prompts[name] = { + 'handler': handler, + 'description': description, + 'arguments': arguments or [] + } + + async def process_message(self, message: JSONRPCMessage) -> Optional[JSONRPCMessage]: + """Process an incoming JSON-RPC message.""" + try: + if isinstance(message, JSONRPCRequest): + return await self._handle_request(message) + elif hasattr(message, 'method'): # Notification + await self._handle_notification(message) + return None + else: + logger.warning(f"Unknown message type: {type(message)}") + return None + + except Exception as e: + logger.exception(f"Error processing message: {e}") + if isinstance(message, JSONRPCRequest): + return JSONRPCResponse( + id=message.id, + error=JSONRPCError( + code=-32603, # Internal error + message=f"Internal server error: {str(e)}" + ) + ) + return None + + async def _handle_request(self, request: JSONRPCRequest) -> JSONRPCResponse: + """Handle a JSON-RPC request.""" + method = request.method + params = request.params or {} + + if method not in self.request_handlers: + return JSONRPCResponse( + id=request.id, + error=JSONRPCError( + code=-32601, # Method not found + message=f"Method not found: {method}" + ) + ) + + try: + handler = self.request_handlers[method] + + # Call handler with proper parameters + if asyncio.iscoroutinefunction(handler): + result = await handler(params) + else: + result = handler(params) + + return JSONRPCResponse(id=request.id, result=result) + + except Exception as e: + logger.exception(f"Error handling request {method}: {e}") + return JSONRPCResponse( + id=request.id, + error=JSONRPCError( + code=-32603, # Internal error + message=str(e) + ) + ) + + async def _handle_notification(self, notification): + """Handle a JSON-RPC notification.""" + method = getattr(notification, 'method', None) + params = getattr(notification, 'params', {}) or {} + + if method in self.notification_handlers: + try: + handler = self.notification_handlers[method] + if asyncio.iscoroutinefunction(handler): + await handler(params) + else: + handler(params) + except Exception as e: + logger.exception(f"Error handling notification {method}: {e}") + else: + logger.warning(f"Unknown notification method: {method}") + + async def _handle_initialize(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Handle initialize request.""" + protocol_version = params.get('protocolVersion') + client_info = params.get('clientInfo', {}) + self.client_capabilities = params.get('capabilities', {}) + + logger.info(f"Initializing server for client: {client_info.get('name', 'Unknown')}") + + # Define server capabilities + self.capabilities = { + "tools": {"listChanged": True} if self.tools else None, + "resources": {"subscribe": True, "listChanged": True} if self.resources else None, + "prompts": {"listChanged": True} if self.prompts else None, + "logging": {}, + } + + # Remove None capabilities + self.capabilities = {k: v for k, v in self.capabilities.items() if v is not None} + + return { + "protocolVersion": protocol_version, + "capabilities": self.capabilities, + "serverInfo": { + "name": self.name, + "version": self.version + } + } + + def _handle_initialized(self, params: Dict[str, Any]): + """Handle initialized notification.""" + self.initialized = True + logger.info("Server initialization completed") + + async def _handle_list_tools(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Handle tools/list request.""" + tools = [] + for name, tool_info in self.tools.items(): + tools.append({ + "name": name, + "description": tool_info['description'], + "inputSchema": tool_info['input_schema'] + }) + + return {"tools": tools} + + async def _handle_call_tool(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Handle tools/call request.""" + name = params.get('name') + arguments = params.get('arguments', {}) + + if name not in self.tools: + raise ValueError(f"Tool not found: {name}") + + tool_info = self.tools[name] + handler = tool_info['handler'] + + try: + # Call the tool handler + if asyncio.iscoroutinefunction(handler): + result = await handler(**arguments) + else: + result = handler(**arguments) + + # Convert result to content format + if isinstance(result, str): + content = [{"type": "text", "text": result}] + elif isinstance(result, dict): + content = [{"type": "text", "text": json.dumps(result, indent=2)}] + elif isinstance(result, list): + content = [{"type": "text", "text": json.dumps(result, indent=2)}] + else: + content = [{"type": "text", "text": str(result)}] + + return { + "content": content, + "isError": False + } + + except Exception as e: + logger.exception(f"Error executing tool {name}: {e}") + return { + "content": [{"type": "text", "text": str(e)}], + "isError": True + } + + async def _handle_list_resources(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Handle resources/list request.""" + resources = [] + for uri, resource_info in self.resources.items(): + resources.append({ + "uri": uri, + "name": resource_info['name'], + "description": resource_info['description'], + "mimeType": "text/plain" # Default, can be customized + }) + + return {"resources": resources} + + async def _handle_read_resource(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Handle resources/read request.""" + uri = params.get('uri') + + if uri not in self.resources: + raise ValueError(f"Resource not found: {uri}") + + resource_info = self.resources[uri] + handler = resource_info['handler'] + + try: + # Call the resource handler + if asyncio.iscoroutinefunction(handler): + result = await handler(uri) + else: + result = handler(uri) + + # Convert result to content format + if isinstance(result, str): + contents = [{"type": "text", "text": result}] + elif isinstance(result, bytes): + contents = [{"type": "blob", "blob": result}] + else: + contents = [{"type": "text", "text": str(result)}] + + return {"contents": contents} + + except Exception as e: + logger.exception(f"Error reading resource {uri}: {e}") + raise + + async def _handle_list_prompts(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Handle prompts/list request.""" + prompts = [] + for name, prompt_info in self.prompts.items(): + prompts.append({ + "name": name, + "description": prompt_info['description'], + "arguments": prompt_info['arguments'] + }) + + return {"prompts": prompts} + + async def _handle_get_prompt(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Handle prompts/get request.""" + name = params.get('name') + arguments = params.get('arguments', {}) + + if name not in self.prompts: + raise ValueError(f"Prompt not found: {name}") + + prompt_info = self.prompts[name] + handler = prompt_info['handler'] + + try: + # Call the prompt handler + if asyncio.iscoroutinefunction(handler): + result = await handler(**arguments) + else: + result = handler(**arguments) + + # Convert result to messages format + if isinstance(result, str): + messages = [{"role": "user", "content": {"type": "text", "text": result}}] + elif isinstance(result, list): + messages = result + elif isinstance(result, dict): + if 'messages' in result: + messages = result['messages'] + else: + messages = [{"role": "user", "content": {"type": "text", "text": json.dumps(result)}}] + else: + messages = [{"role": "user", "content": {"type": "text", "text": str(result)}}] + + return { + "description": prompt_info['description'], + "messages": messages + } + + except Exception as e: + logger.exception(f"Error getting prompt {name}: {e}") + raise + + def _handle_progress(self, params: Dict[str, Any]): + """Handle progress notification.""" + progress_token = params.get('progressToken') + progress = params.get('progress') + total = params.get('total') + + logger.info(f"Progress update: {progress}/{total} (token: {progress_token})") + + async def run_stdio(self): + """Run server with stdio transport.""" + server = Server() + + @server.list_tools() + async def list_tools() -> List[Tool]: + tools = [] + for name, tool_info in self.tools.items(): + tools.append(Tool( + name=name, + description=tool_info['description'], + inputSchema=tool_info['input_schema'] + )) + return tools + + @server.call_tool() + async def call_tool(name: str, arguments: dict) -> List[TextContent]: + if name not in self.tools: + raise ValueError(f"Tool not found: {name}") + + tool_info = self.tools[name] + handler = tool_info['handler'] + + if asyncio.iscoroutinefunction(handler): + result = await handler(**arguments) + else: + result = handler(**arguments) + + return [TextContent(type="text", text=str(result))] + + # Add resource and prompt handlers similarly... + + async with stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializeResult( + protocolVersion="2025-06-18", + capabilities=server.get_capabilities(), + serverInfo=ServerInfo(name=self.name, version=self.version) + ) + ) + +# Usage example +def create_calculator_server(): + """Create a low-level calculator server.""" + server = LowLevelServer("Calculator Server", "1.0.0") + + # Register calculator tools + def add(a: float, b: float) -> float: + """Add two numbers.""" + return a + b + + def multiply(a: float, b: float) -> float: + """Multiply two numbers.""" + return a * b + + def divide(a: float, b: float) -> float: + """Divide two numbers.""" + if b == 0: + raise ValueError("Cannot divide by zero") + return a / b + + # Register tools with schemas + server.register_tool( + "add", + add, + "Add two numbers together", + { + "type": "object", + "properties": { + "a": {"type": "number", "description": "First number"}, + "b": {"type": "number", "description": "Second number"} + }, + "required": ["a", "b"] + } + ) + + server.register_tool( + "multiply", + multiply, + "Multiply two numbers", + { + "type": "object", + "properties": { + "a": {"type": "number", "description": "First number"}, + "b": {"type": "number", "description": "Second number"} + }, + "required": ["a", "b"] + } + ) + + server.register_tool( + "divide", + divide, + "Divide first number by second number", + { + "type": "object", + "properties": { + "a": {"type": "number", "description": "Dividend"}, + "b": {"type": "number", "description": "Divisor"} + }, + "required": ["a", "b"] + } + ) + + # Register a configuration resource + def get_config(uri: str) -> str: + """Get server configuration.""" + if uri == "config://settings": + return json.dumps({ + "precision": 6, + "max_operations": 1000, + "supported_operations": ["add", "multiply", "divide"] + }, indent=2) + return "Configuration not found" + + server.register_resource( + "config://settings", + get_config, + "Server Settings", + "Calculator server configuration" + ) + + # Register a calculation prompt + def math_prompt(operation: str = "add", **kwargs) -> str: + """Generate a math problem prompt.""" + if operation == "add": + return f"Please add the following numbers: {kwargs.get('numbers', [1, 2, 3])}" + elif operation == "multiply": + return f"Please multiply these numbers: {kwargs.get('numbers', [2, 3, 4])}" + else: + return f"Please perform {operation} on the given numbers" + + server.register_prompt( + "math_problem", + math_prompt, + "Generate a math problem", + [ + {"name": "operation", "description": "Type of operation", "required": False}, + {"name": "numbers", "description": "Numbers to use", "required": False} + ] + ) + + return server + +if __name__ == "__main__": + # Create and run the server + calc_server = create_calculator_server() + asyncio.run(calc_server.run_stdio()) +``` + +## Custom transport implementation + +### HTTP transport + +```python +""" +Custom HTTP transport for low-level MCP server. +""" + +import asyncio +import json +from aiohttp import web, WSMsgType +from typing import Dict, Any, Optional + +class HttpTransport: + """HTTP transport for MCP server.""" + + def __init__(self, server: LowLevelServer, host: str = "localhost", port: int = 8000): + self.server = server + self.host = host + self.port = port + self.app = web.Application() + self.sessions: Dict[str, Any] = {} + + # Setup routes + self._setup_routes() + + def _setup_routes(self): + """Setup HTTP routes.""" + self.app.router.add_post('/mcp', self._handle_http_request) + self.app.router.add_get('/mcp/ws', self._handle_websocket) + self.app.router.add_get('/health', self._health_check) + self.app.router.add_get('/', self._index) + + async def _handle_http_request(self, request): + """Handle HTTP POST request.""" + try: + data = await request.json() + + # Convert to JSONRPCRequest + message = self._parse_jsonrpc_message(data) + if not message: + return web.json_response( + {"error": "Invalid JSON-RPC message"}, + status=400 + ) + + # Process message + response = await self.server.process_message(message) + + if response: + return web.json_response(self._serialize_response(response)) + else: + return web.Response(status=204) # No content for notifications + + except json.JSONDecodeError: + return web.json_response( + {"error": "Invalid JSON"}, + status=400 + ) + except Exception as e: + return web.json_response( + {"error": str(e)}, + status=500 + ) + + async def _handle_websocket(self, request): + """Handle WebSocket connection.""" + ws = web.WebSocketResponse() + await ws.prepare(request) + + session_id = id(ws) + self.sessions[session_id] = ws + + try: + async for msg in ws: + if msg.type == WSMsgType.TEXT: + try: + data = json.loads(msg.data) + message = self._parse_jsonrpc_message(data) + + if message: + response = await self.server.process_message(message) + if response: + await ws.send_str(json.dumps(self._serialize_response(response))) + + except Exception as e: + error_response = { + "jsonrpc": "2.0", + "error": {"code": -32603, "message": str(e)}, + "id": None + } + await ws.send_str(json.dumps(error_response)) + + elif msg.type == WSMsgType.ERROR: + print(f'WebSocket error: {ws.exception()}') + break + + finally: + if session_id in self.sessions: + del self.sessions[session_id] + + return ws + + async def _health_check(self, request): + """Health check endpoint.""" + return web.json_response({ + "status": "healthy", + "server": self.server.name, + "version": self.server.version, + "initialized": self.server.initialized + }) + + async def _index(self, request): + """Index page with server info.""" + html = f""" + + + + {self.server.name} + + +

{self.server.name}

+

Version: {self.server.version}

+

Status: {"Initialized" if self.server.initialized else "Not initialized"}

+

Endpoints:

+
    +
  • POST /mcp - JSON-RPC over HTTP
  • +
  • GET /mcp/ws - WebSocket connection
  • +
  • GET /health - Health check
  • +
+ + + """ + return web.Response(text=html, content_type='text/html') + + def _parse_jsonrpc_message(self, data: Dict[str, Any]): + """Parse JSON-RPC message from data.""" + if not isinstance(data, dict) or data.get('jsonrpc') != '2.0': + return None + + if 'method' in data: + # Request or notification + return type('JSONRPCMessage', (), { + 'jsonrpc': data['jsonrpc'], + 'method': data['method'], + 'params': data.get('params'), + 'id': data.get('id') + })() + + return None + + def _serialize_response(self, response) -> Dict[str, Any]: + """Serialize response to JSON-RPC format.""" + result = { + "jsonrpc": "2.0", + "id": getattr(response, 'id', None) + } + + if hasattr(response, 'result'): + result["result"] = response.result + elif hasattr(response, 'error'): + result["error"] = { + "code": response.error.code, + "message": response.error.message, + "data": getattr(response.error, 'data', None) + } + + return result + + async def run(self): + """Run the HTTP server.""" + runner = web.AppRunner(self.app) + await runner.setup() + + site = web.TCPSite(runner, self.host, self.port) + await site.start() + + print(f"MCP server running on http://{self.host}:{self.port}") + + try: + await asyncio.Future() # Run forever + except KeyboardInterrupt: + pass + finally: + await runner.cleanup() + +# Usage example +async def run_http_server(): + """Run calculator server with HTTP transport.""" + server = create_calculator_server() + transport = HttpTransport(server, "localhost", 8000) + await transport.run() + +if __name__ == "__main__": + asyncio.run(run_http_server()) +``` + +## Advanced features + +### Custom protocol extensions + +```python +""" +Custom protocol extensions for MCP server. +""" + +from typing import Any, Dict, List, Optional +import time +import uuid + +class ExtendedServer(LowLevelServer): + """MCP server with custom protocol extensions.""" + + def __init__(self, name: str, version: str = "1.0.0"): + super().__init__(name, version) + + # Extension state + self.metrics = {} + self.sessions = {} + + # Register extension handlers + self._register_extensions() + + def _register_extensions(self): + """Register custom protocol extensions.""" + # Custom methods + self.request_handlers.update({ + "server/metrics": self._handle_get_metrics, + "server/status": self._handle_get_status, + "tools/batch": self._handle_batch_tools, + "session/create": self._handle_create_session, + "session/destroy": self._handle_destroy_session, + }) + + # Custom notifications + self.notification_handlers.update({ + "client/heartbeat": self._handle_heartbeat, + "metrics/report": self._handle_metrics_report, + }) + + async def process_message(self, message): + """Enhanced message processing with metrics.""" + start_time = time.time() + method = getattr(message, 'method', 'unknown') + + try: + result = await super().process_message(message) + + # Record success metrics + self._record_metric(method, time.time() - start_time, True) + + return result + + except Exception as e: + # Record error metrics + self._record_metric(method, time.time() - start_time, False) + raise + + def _record_metric(self, method: str, duration: float, success: bool): + """Record operation metrics.""" + if method not in self.metrics: + self.metrics[method] = { + 'count': 0, + 'success_count': 0, + 'error_count': 0, + 'total_duration': 0.0, + 'avg_duration': 0.0, + 'last_called': None + } + + metric = self.metrics[method] + metric['count'] += 1 + metric['total_duration'] += duration + metric['avg_duration'] = metric['total_duration'] / metric['count'] + metric['last_called'] = time.time() + + if success: + metric['success_count'] += 1 + else: + metric['error_count'] += 1 + + async def _handle_get_metrics(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Handle server/metrics request.""" + return { + "metrics": self.metrics, + "server_uptime": time.time() - getattr(self, '_start_time', time.time()), + "active_sessions": len(self.sessions) + } + + async def _handle_get_status(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Handle server/status request.""" + return { + "name": self.name, + "version": self.version, + "initialized": self.initialized, + "capabilities": self.capabilities, + "tools_count": len(self.tools), + "resources_count": len(self.resources), + "prompts_count": len(self.prompts), + "uptime": time.time() - getattr(self, '_start_time', time.time()) + } + + async def _handle_batch_tools(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Handle tools/batch request for executing multiple tools.""" + calls = params.get('calls', []) + results = [] + + for call in calls: + tool_name = call.get('name') + arguments = call.get('arguments', {}) + call_id = call.get('id', str(uuid.uuid4())) + + try: + # Use existing tool call logic + tool_result = await self._handle_call_tool({ + 'name': tool_name, + 'arguments': arguments + }) + + results.append({ + 'id': call_id, + 'result': tool_result, + 'success': True + }) + + except Exception as e: + results.append({ + 'id': call_id, + 'error': str(e), + 'success': False + }) + + return {"results": results} + + async def _handle_create_session(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Handle session/create request.""" + session_id = str(uuid.uuid4()) + session_name = params.get('name', f"Session {session_id[:8]}") + + self.sessions[session_id] = { + 'id': session_id, + 'name': session_name, + 'created_at': time.time(), + 'last_activity': time.time(), + 'context': params.get('context', {}) + } + + return { + "session_id": session_id, + "name": session_name, + "created_at": self.sessions[session_id]['created_at'] + } + + async def _handle_destroy_session(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Handle session/destroy request.""" + session_id = params.get('session_id') + + if session_id in self.sessions: + del self.sessions[session_id] + return {"success": True, "message": f"Session {session_id} destroyed"} + else: + raise ValueError(f"Session not found: {session_id}") + + def _handle_heartbeat(self, params: Dict[str, Any]): + """Handle client/heartbeat notification.""" + client_id = params.get('client_id', 'unknown') + timestamp = params.get('timestamp', time.time()) + + logger.info(f"Heartbeat from client {client_id} at {timestamp}") + + def _handle_metrics_report(self, params: Dict[str, Any]): + """Handle client metrics report.""" + client_metrics = params.get('metrics', {}) + client_id = params.get('client_id', 'unknown') + + logger.info(f"Received metrics from client {client_id}: {client_metrics}") + +# Example with custom extensions +def create_extended_server(): + """Create a server with custom protocol extensions.""" + server = ExtendedServer("Extended Calculator", "2.0.0") + server._start_time = time.time() + + # Add standard calculator tools + server.register_tool( + "add", + lambda a, b: a + b, + "Add two numbers", + { + "type": "object", + "properties": { + "a": {"type": "number"}, + "b": {"type": "number"} + }, + "required": ["a", "b"] + } + ) + + # Add extended tool with session context + async def contextual_calculate(operation: str, numbers: List[float], session_id: str = None) -> Dict[str, Any]: + """Perform calculation with session context.""" + session = None + if session_id and session_id in server.sessions: + session = server.sessions[session_id] + session['last_activity'] = time.time() + + # Perform calculation + if operation == "sum": + result = sum(numbers) + elif operation == "product": + result = 1 + for num in numbers: + result *= num + elif operation == "average": + result = sum(numbers) / len(numbers) if numbers else 0 + else: + raise ValueError(f"Unknown operation: {operation}") + + return { + "result": result, + "operation": operation, + "input_numbers": numbers, + "session_context": session['context'] if session else None + } + + server.register_tool( + "contextual_calculate", + contextual_calculate, + "Perform calculation with session context", + { + "type": "object", + "properties": { + "operation": {"type": "string", "enum": ["sum", "product", "average"]}, + "numbers": {"type": "array", "items": {"type": "number"}}, + "session_id": {"type": "string"} + }, + "required": ["operation", "numbers"] + } + ) + + return server + +if __name__ == "__main__": + # Run extended server + server = create_extended_server() + asyncio.run(server.run_stdio()) +``` + +## Performance optimization + +### Concurrent request handling + +```python +""" +High-performance server with concurrent request handling. +""" + +import asyncio +import time +from typing import Dict, Any, List +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +import psutil + +@dataclass +class PerformanceConfig: + """Configuration for performance optimizations.""" + max_concurrent_requests: int = 100 + request_timeout: float = 30.0 + thread_pool_size: int = 10 + enable_caching: bool = True + cache_ttl: float = 300.0 # 5 minutes + enable_metrics: bool = True + +class HighPerformanceServer(LowLevelServer): + """High-performance MCP server with optimizations.""" + + def __init__(self, name: str, version: str = "1.0.0", config: PerformanceConfig = None): + super().__init__(name, version) + self.config = config or PerformanceConfig() + + # Performance components + self.thread_pool = ThreadPoolExecutor(max_workers=self.config.thread_pool_size) + self.request_semaphore = asyncio.Semaphore(self.config.max_concurrent_requests) + self.cache: Dict[str, Dict[str, Any]] = {} + self.request_queue = asyncio.Queue() + + # Metrics + self.performance_metrics = { + 'requests_per_second': 0, + 'average_response_time': 0, + 'active_requests': 0, + 'cache_hit_rate': 0, + 'queue_size': 0 + } + + # Start background tasks + self._start_background_tasks() + + def _start_background_tasks(self): + """Start background performance monitoring tasks.""" + if self.config.enable_metrics: + asyncio.create_task(self._metrics_collector()) + if self.config.enable_caching: + asyncio.create_task(self._cache_cleanup()) + + async def process_message(self, message) -> Optional[Any]: + """Process message with performance optimizations.""" + async with self.request_semaphore: + # Add to queue for metrics + await self.request_queue.put(time.time()) + + try: + # Apply timeout + return await asyncio.wait_for( + self._process_message_internal(message), + timeout=self.config.request_timeout + ) + except asyncio.TimeoutError: + logger.warning(f"Request timeout for method: {getattr(message, 'method', 'unknown')}") + if hasattr(message, 'id'): + return self._create_error_response(message.id, -32603, "Request timeout") + return None + finally: + self.performance_metrics['active_requests'] = self.request_semaphore._value + + async def _process_message_internal(self, message): + """Internal message processing with caching.""" + method = getattr(message, 'method', None) + params = getattr(message, 'params', {}) + + # Check cache for read-only operations + if self.config.enable_caching and method in ['tools/list', 'resources/list', 'prompts/list']: + cache_key = f"{method}:{hash(str(params))}" + cached_result = self._get_cached_result(cache_key) + if cached_result: + return cached_result + + # Process message + start_time = time.time() + result = await super().process_message(message) + duration = time.time() - start_time + + # Cache result for read-only operations + if self.config.enable_caching and method in ['tools/list', 'resources/list', 'prompts/list'] and result: + cache_key = f"{method}:{hash(str(params))}" + self._cache_result(cache_key, result, duration) + + return result + + def _get_cached_result(self, cache_key: str) -> Optional[Any]: + """Get result from cache if valid.""" + if cache_key in self.cache: + cache_entry = self.cache[cache_key] + if time.time() - cache_entry['timestamp'] < self.config.cache_ttl: + return cache_entry['result'] + else: + del self.cache[cache_key] + return None + + def _cache_result(self, cache_key: str, result: Any, processing_time: float): + """Cache result with metadata.""" + self.cache[cache_key] = { + 'result': result, + 'timestamp': time.time(), + 'processing_time': processing_time + } + + async def _metrics_collector(self): + """Collect performance metrics.""" + request_times = [] + + while True: + try: + # Calculate requests per second + current_time = time.time() + recent_requests = [] + + # Drain queue and collect recent requests + while not self.request_queue.empty(): + try: + request_time = self.request_queue.get_nowait() + if current_time - request_time < 60: # Last minute + recent_requests.append(request_time) + except asyncio.QueueEmpty: + break + + # Update metrics + self.performance_metrics['requests_per_second'] = len(recent_requests) / 60 + self.performance_metrics['queue_size'] = self.request_queue.qsize() + + # Calculate cache hit rate + total_cache_requests = len(self.cache) + if total_cache_requests > 0: + # Simplified cache hit rate calculation + valid_cache_entries = sum( + 1 for entry in self.cache.values() + if current_time - entry['timestamp'] < self.config.cache_ttl + ) + self.performance_metrics['cache_hit_rate'] = valid_cache_entries / total_cache_requests + + # System metrics + process = psutil.Process() + self.performance_metrics.update({ + 'memory_usage_mb': process.memory_info().rss / 1024 / 1024, + 'cpu_percent': process.cpu_percent(), + 'thread_count': process.num_threads() + }) + + await asyncio.sleep(10) # Update every 10 seconds + + except Exception as e: + logger.exception(f"Error in metrics collector: {e}") + await asyncio.sleep(10) + + async def _cache_cleanup(self): + """Clean up expired cache entries.""" + while True: + try: + current_time = time.time() + expired_keys = [ + key for key, entry in self.cache.items() + if current_time - entry['timestamp'] > self.config.cache_ttl + ] + + for key in expired_keys: + del self.cache[key] + + if expired_keys: + logger.info(f"Cleaned up {len(expired_keys)} expired cache entries") + + await asyncio.sleep(60) # Cleanup every minute + + except Exception as e: + logger.exception(f"Error in cache cleanup: {e}") + await asyncio.sleep(60) + + def _create_error_response(self, request_id: Any, code: int, message: str): + """Create JSON-RPC error response.""" + return type('JSONRPCResponse', (), { + 'id': request_id, + 'error': type('JSONRPCError', (), { + 'code': code, + 'message': message + })() + })() + + # Add performance monitoring tool + async def _handle_performance_stats(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Handle performance/stats request.""" + return { + "performance_metrics": self.performance_metrics, + "config": { + "max_concurrent_requests": self.config.max_concurrent_requests, + "request_timeout": self.config.request_timeout, + "thread_pool_size": self.config.thread_pool_size, + "cache_enabled": self.config.enable_caching, + "cache_ttl": self.config.cache_ttl + }, + "cache_stats": { + "total_entries": len(self.cache), + "memory_usage_estimate": sum( + len(str(entry)) for entry in self.cache.values() + ) + } + } + +# Performance monitoring tool +def add_performance_monitoring(server: HighPerformanceServer): + """Add performance monitoring tools to server.""" + + server.request_handlers["performance/stats"] = server._handle_performance_stats + + def get_system_info() -> Dict[str, Any]: + """Get system information.""" + try: + process = psutil.Process() + return { + "cpu_count": psutil.cpu_count(), + "memory_total_gb": psutil.virtual_memory().total / 1024 / 1024 / 1024, + "memory_available_gb": psutil.virtual_memory().available / 1024 / 1024 / 1024, + "process_memory_mb": process.memory_info().rss / 1024 / 1024, + "process_cpu_percent": process.cpu_percent(), + "open_files": len(process.open_files()), + "connections": len(process.connections()) + } + except Exception as e: + return {"error": str(e)} + + server.register_tool( + "system_info", + get_system_info, + "Get system resource information", + {"type": "object", "properties": {}} + ) + +# Usage example +def create_high_performance_server(): + """Create high-performance server.""" + config = PerformanceConfig( + max_concurrent_requests=200, + request_timeout=60.0, + thread_pool_size=20, + enable_caching=True, + cache_ttl=600.0 + ) + + server = HighPerformanceServer("High Performance Calculator", "3.0.0", config) + add_performance_monitoring(server) + + # Add CPU-intensive tool + def fibonacci(n: int) -> int: + """Calculate Fibonacci number (CPU intensive).""" + if n <= 1: + return n + return fibonacci(n - 1) + fibonacci(n - 2) + + server.register_tool( + "fibonacci", + fibonacci, + "Calculate Fibonacci number (CPU intensive)", + { + "type": "object", + "properties": { + "n": {"type": "integer", "minimum": 0, "maximum": 35} + }, + "required": ["n"] + } + ) + + return server + +if __name__ == "__main__": + server = create_high_performance_server() + asyncio.run(server.run_stdio()) +``` + +## Best practices + +### Architecture guidelines + +- **Separation of concerns** - Keep protocol handling separate from business logic +- **Error boundaries** - Implement comprehensive error handling at each layer +- **Resource management** - Properly manage connections, memory, and file handles +- **Monitoring** - Add metrics and logging for production deployments +- **Testing** - Unit test individual handlers and integration test full workflows + +### Security considerations + +- **Input validation** - Validate all incoming parameters +- **Rate limiting** - Prevent abuse with request rate limits +- **Authentication** - Implement proper authentication for sensitive operations +- **Logging** - Log security events and access attempts +- **Resource limits** - Set limits on computation and memory usage + +### Performance optimization + +- **Async operations** - Use async/await throughout +- **Connection pooling** - Pool database and external service connections +- **Caching** - Cache expensive computations and frequently accessed data +- **Concurrency limits** - Prevent resource exhaustion with semaphores +- **Monitoring** - Track performance metrics and optimize bottlenecks + +## Next steps + +- **[Structured output](structured-output.md)** - Advanced output formatting +- **[Completions](completions.md)** - LLM integration patterns +- **[Authentication](authentication.md)** - Server security implementation +- **[Streamable HTTP](streamable-http.md)** - Modern transport details \ No newline at end of file diff --git a/docs/oauth-clients.md b/docs/oauth-clients.md new file mode 100644 index 000000000..6d12fa541 --- /dev/null +++ b/docs/oauth-clients.md @@ -0,0 +1,971 @@ +# OAuth for clients + +Learn how to implement OAuth 2.1 authentication in MCP clients to securely connect to authenticated servers. + +## Overview + +OAuth for MCP clients enables: + +- **Secure authentication** - Industry-standard OAuth 2.1 flows +- **Token management** - Automatic token refresh and storage +- **Multiple providers** - Support for various OAuth providers +- **Credential security** - Secure storage and transmission of credentials + +## Basic OAuth client + +### Simple OAuth client + +```python +""" +MCP client with OAuth 2.1 authentication. +""" + +import asyncio +from mcp import ClientSession +from mcp.client.oauth import OAuthClient, OAuthConfig +from mcp.client.streamable_http import streamablehttp_client + +async def oauth_client_example(): + """Connect to MCP server with OAuth authentication.""" + + # Configure OAuth + oauth_config = OAuthConfig( + client_id="your-client-id", + client_secret="your-client-secret", + authorization_url="https://auth.example.com/oauth/authorize", + token_url="https://auth.example.com/oauth/token", + redirect_uri="http://localhost:8080/callback", + scope="mcp:read mcp:write" + ) + + # Create OAuth client + oauth_client = OAuthClient(oauth_config) + + # Authenticate (will open browser for authorization) + await oauth_client.authenticate() + + # Connect to MCP server with OAuth + server_url = "https://api.example.com/mcp" + + async with streamablehttp_client(server_url) as (read, write, session_info): + # Add OAuth headers to requests + oauth_client.add_auth_headers(session_info.headers) + + async with ClientSession(read, write) as session: + # Initialize with authentication + await session.initialize() + + # Now you can make authenticated requests + tools = await session.list_tools() + print(f"Available tools: {[tool.name for tool in tools.tools]}") + + # Call tools with authentication + result = await session.call_tool("protected_tool", {"data": "test"}) + if result.content: + content = result.content[0] + if hasattr(content, 'text'): + print(f"Result: {content.text}") + +if __name__ == "__main__": + asyncio.run(oauth_client_example()) +``` + +## OAuth configuration + +### Configuration options + +```python +""" +Comprehensive OAuth configuration. +""" + +from mcp.client.oauth import OAuthConfig, OAuthFlow + +# Standard authorization code flow +standard_config = OAuthConfig( + client_id="your-client-id", + client_secret="your-client-secret", + authorization_url="https://auth.example.com/oauth/authorize", + token_url="https://auth.example.com/oauth/token", + redirect_uri="http://localhost:8080/callback", + scope="mcp:read mcp:write mcp:admin", + flow=OAuthFlow.AUTHORIZATION_CODE +) + +# PKCE flow for public clients +pkce_config = OAuthConfig( + client_id="public-client-id", + authorization_url="https://auth.example.com/oauth/authorize", + token_url="https://auth.example.com/oauth/token", + redirect_uri="http://localhost:8080/callback", + scope="mcp:read mcp:write", + flow=OAuthFlow.AUTHORIZATION_CODE_PKCE, + code_challenge_method="S256" +) + +# Client credentials flow for service-to-service +service_config = OAuthConfig( + client_id="service-client-id", + client_secret="service-client-secret", + token_url="https://auth.example.com/oauth/token", + scope="mcp:service", + flow=OAuthFlow.CLIENT_CREDENTIALS +) + +# Device code flow for CLI applications +device_config = OAuthConfig( + client_id="device-client-id", + authorization_url="https://auth.example.com/device/authorize", + token_url="https://auth.example.com/oauth/token", + device_authorization_url="https://auth.example.com/device/code", + scope="mcp:read mcp:write", + flow=OAuthFlow.DEVICE_CODE +) +``` + +### Environment configuration + +Store OAuth credentials securely: + +```bash +# .env file for OAuth configuration +OAUTH_CLIENT_ID=your-client-id +OAUTH_CLIENT_SECRET=your-client-secret +OAUTH_AUTHORIZATION_URL=https://auth.example.com/oauth/authorize +OAUTH_TOKEN_URL=https://auth.example.com/oauth/token +OAUTH_REDIRECT_URI=http://localhost:8080/callback +OAUTH_SCOPE=mcp:read mcp:write +``` + +Load from environment: + +```python +import os +from mcp.client.oauth import OAuthConfig + +def load_oauth_config() -> OAuthConfig: + """Load OAuth configuration from environment.""" + return OAuthConfig( + client_id=os.getenv("OAUTH_CLIENT_ID"), + client_secret=os.getenv("OAUTH_CLIENT_SECRET"), + authorization_url=os.getenv("OAUTH_AUTHORIZATION_URL"), + token_url=os.getenv("OAUTH_TOKEN_URL"), + redirect_uri=os.getenv("OAUTH_REDIRECT_URI"), + scope=os.getenv("OAUTH_SCOPE", "mcp:read") + ) +``` + +## Token management + +### Automatic token refresh + +```python +""" +OAuth client with automatic token refresh. +""" + +import asyncio +import json +from pathlib import Path +from mcp.client.oauth import OAuthClient, TokenStore + +class FileTokenStore(TokenStore): + """Token store that persists tokens to disk.""" + + def __init__(self, token_file: str = ".oauth_tokens.json"): + self.token_file = Path(token_file) + + async def save_tokens(self, tokens: dict): + """Save tokens to file.""" + with open(self.token_file, 'w') as f: + json.dump(tokens, f, indent=2) + + async def load_tokens(self) -> dict | None: + """Load tokens from file.""" + if not self.token_file.exists(): + return None + + try: + with open(self.token_file, 'r') as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + return None + + async def clear_tokens(self): + """Clear stored tokens.""" + if self.token_file.exists(): + self.token_file.unlink() + +class AutoRefreshOAuthClient(OAuthClient): + """OAuth client with automatic token refresh.""" + + def __init__(self, config: OAuthConfig, token_store: TokenStore): + super().__init__(config) + self.token_store = token_store + self._tokens = None + + async def authenticate(self): + """Authenticate with token refresh support.""" + # Try to load existing tokens + self._tokens = await self.token_store.load_tokens() + + if self._tokens: + # Check if tokens are still valid + if await self._are_tokens_valid(): + return + + # Try to refresh tokens + if await self._refresh_tokens(): + return + + # Perform fresh authentication + await super().authenticate() + + # Save new tokens + if self._tokens: + await self.token_store.save_tokens(self._tokens) + + async def _are_tokens_valid(self) -> bool: + """Check if current tokens are valid.""" + if not self._tokens or 'access_token' not in self._tokens: + return False + + # Check expiration if available + if 'expires_at' in self._tokens: + import time + return time.time() < self._tokens['expires_at'] + + return True + + async def _refresh_tokens(self) -> bool: + """Refresh access tokens using refresh token.""" + if not self._tokens or 'refresh_token' not in self._tokens: + return False + + try: + # Make refresh request + refresh_data = { + 'grant_type': 'refresh_token', + 'refresh_token': self._tokens['refresh_token'], + 'client_id': self.config.client_id + } + + if self.config.client_secret: + refresh_data['client_secret'] = self.config.client_secret + + async with aiohttp.ClientSession() as session: + async with session.post( + self.config.token_url, + data=refresh_data + ) as response: + if response.status == 200: + new_tokens = await response.json() + + # Update tokens + self._tokens.update(new_tokens) + + # Calculate expiration + if 'expires_in' in new_tokens: + import time + self._tokens['expires_at'] = time.time() + new_tokens['expires_in'] + + # Save updated tokens + await self.token_store.save_tokens(self._tokens) + return True + + return False + + except Exception: + return False + + def get_auth_headers(self) -> dict[str, str]: + """Get authentication headers for requests.""" + if not self._tokens or 'access_token' not in self._tokens: + raise RuntimeError("Not authenticated") + + return { + 'Authorization': f"Bearer {self._tokens['access_token']}" + } + +# Usage example +async def auto_refresh_example(): + """Example using auto-refresh OAuth client.""" + config = load_oauth_config() + token_store = FileTokenStore() + oauth_client = AutoRefreshOAuthClient(config, token_store) + + # Authenticate (will use stored tokens if valid) + await oauth_client.authenticate() + + # Use client with automatic token refresh + server_url = "https://api.example.com/mcp" + + async with streamablehttp_client(server_url) as (read, write, session_info): + # Add auth headers + session_info.headers.update(oauth_client.get_auth_headers()) + + async with ClientSession(read, write) as session: + await session.initialize() + + # Make authenticated requests + tools = await session.list_tools() + print(f"Available tools: {len(tools.tools)}") + +if __name__ == "__main__": + asyncio.run(auto_refresh_example()) +``` + +## OAuth flows + +### Authorization code flow + +```python +""" +Standard authorization code flow implementation. +""" + +import asyncio +import webbrowser +from urllib.parse import urlparse, parse_qs +from aiohttp import web +import aiohttp + +class AuthorizationCodeFlow: + """OAuth 2.1 Authorization Code Flow.""" + + def __init__(self, config: OAuthConfig): + self.config = config + self.auth_code = None + self.auth_error = None + + async def authenticate(self) -> dict: + """Perform authorization code flow.""" + # Start local callback server + app = web.Application() + app.router.add_get('/callback', self._handle_callback) + + runner = web.AppRunner(app) + await runner.setup() + + # Extract port from redirect URI + parsed_uri = urlparse(self.config.redirect_uri) + port = parsed_uri.port or 8080 + + site = web.TCPSite(runner, 'localhost', port) + await site.start() + + try: + # Generate authorization URL + auth_url = self._build_auth_url() + + # Open browser for user authorization + print(f"Opening browser for authorization: {auth_url}") + webbrowser.open(auth_url) + + # Wait for callback + await self._wait_for_callback() + + if self.auth_error: + raise RuntimeError(f"Authorization failed: {self.auth_error}") + + if not self.auth_code: + raise RuntimeError("No authorization code received") + + # Exchange code for tokens + return await self._exchange_code_for_tokens() + + finally: + await runner.cleanup() + + def _build_auth_url(self) -> str: + """Build authorization URL.""" + from urllib.parse import urlencode + import secrets + + # Generate state for CSRF protection + state = secrets.token_urlsafe(32) + + params = { + 'response_type': 'code', + 'client_id': self.config.client_id, + 'redirect_uri': self.config.redirect_uri, + 'scope': self.config.scope, + 'state': state + } + + return f"{self.config.authorization_url}?{urlencode(params)}" + + async def _handle_callback(self, request): + """Handle OAuth callback.""" + # Extract parameters + code = request.query.get('code') + error = request.query.get('error') + + if error: + self.auth_error = error + else: + self.auth_code = code + + # Return success page + return web.Response( + text="Authorization complete. You can close this window.", + content_type='text/html' + ) + + async def _wait_for_callback(self): + """Wait for OAuth callback.""" + timeout = 300 # 5 minutes + interval = 0.1 + + for _ in range(int(timeout / interval)): + if self.auth_code or self.auth_error: + return + await asyncio.sleep(interval) + + raise TimeoutError("Authorization timeout") + + async def _exchange_code_for_tokens(self) -> dict: + """Exchange authorization code for tokens.""" + token_data = { + 'grant_type': 'authorization_code', + 'code': self.auth_code, + 'redirect_uri': self.config.redirect_uri, + 'client_id': self.config.client_id + } + + if self.config.client_secret: + token_data['client_secret'] = self.config.client_secret + + async with aiohttp.ClientSession() as session: + async with session.post( + self.config.token_url, + data=token_data + ) as response: + if response.status != 200: + error_text = await response.text() + raise RuntimeError(f"Token exchange failed: {error_text}") + + tokens = await response.json() + + # Add expiration timestamp + if 'expires_in' in tokens: + import time + tokens['expires_at'] = time.time() + tokens['expires_in'] + + return tokens +``` + +### Client credentials flow + +```python +""" +Client credentials flow for service-to-service authentication. +""" + +async def client_credentials_flow(config: OAuthConfig) -> dict: + """Perform client credentials flow.""" + if not config.client_secret: + raise ValueError("Client secret required for client credentials flow") + + token_data = { + 'grant_type': 'client_credentials', + 'client_id': config.client_id, + 'client_secret': config.client_secret, + 'scope': config.scope + } + + async with aiohttp.ClientSession() as session: + async with session.post( + config.token_url, + data=token_data + ) as response: + if response.status != 200: + error_text = await response.text() + raise RuntimeError(f"Client credentials flow failed: {error_text}") + + tokens = await response.json() + + # Add expiration timestamp + if 'expires_in' in tokens: + import time + tokens['expires_at'] = time.time() + tokens['expires_in'] + + return tokens + +# Usage example +async def service_auth_example(): + """Service-to-service authentication example.""" + config = OAuthConfig( + client_id="service-client", + client_secret="service-secret", + token_url="https://auth.example.com/oauth/token", + scope="mcp:service", + flow=OAuthFlow.CLIENT_CREDENTIALS + ) + + tokens = await client_credentials_flow(config) + + # Use tokens for authenticated requests + headers = {'Authorization': f"Bearer {tokens['access_token']}"} + + async with streamablehttp_client( + "https://api.example.com/mcp", + headers=headers + ) as (read, write, session_info): + async with ClientSession(read, write) as session: + await session.initialize() + print("Service authenticated successfully") +``` + +### Device code flow + +```python +""" +Device code flow for CLI and limited-input devices. +""" + +async def device_code_flow(config: OAuthConfig) -> dict: + """Perform device code flow.""" + # Request device code + device_data = { + 'client_id': config.client_id, + 'scope': config.scope + } + + async with aiohttp.ClientSession() as session: + # Get device code + async with session.post( + config.device_authorization_url, + data=device_data + ) as response: + if response.status != 200: + error_text = await response.text() + raise RuntimeError(f"Device authorization failed: {error_text}") + + device_response = await response.json() + + # Display user instructions + print(f"Visit: {device_response['verification_uri']}") + print(f"Enter code: {device_response['user_code']}") + print("Waiting for authorization...") + + # Poll for tokens + poll_interval = device_response.get('interval', 5) + expires_in = device_response.get('expires_in', 1800) + device_code = device_response['device_code'] + + import time + start_time = time.time() + + while time.time() - start_time < expires_in: + await asyncio.sleep(poll_interval) + + # Poll token endpoint + poll_data = { + 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', + 'device_code': device_code, + 'client_id': config.client_id + } + + async with session.post( + config.token_url, + data=poll_data + ) as poll_response: + if poll_response.status == 200: + tokens = await poll_response.json() + + # Add expiration timestamp + if 'expires_in' in tokens: + tokens['expires_at'] = time.time() + tokens['expires_in'] + + print("Authorization successful!") + return tokens + + elif poll_response.status == 400: + error_response = await poll_response.json() + error_code = error_response.get('error') + + if error_code == 'authorization_pending': + continue # Keep polling + elif error_code == 'slow_down': + poll_interval += 5 # Increase interval + continue + elif error_code in ['access_denied', 'expired_token']: + raise RuntimeError(f"Authorization failed: {error_code}") + + raise TimeoutError("Device authorization timeout") + +# Usage example +async def device_auth_example(): + """Device code flow example.""" + config = OAuthConfig( + client_id="device-client", + authorization_url="https://auth.example.com/device/authorize", + token_url="https://auth.example.com/oauth/token", + device_authorization_url="https://auth.example.com/device/code", + scope="mcp:read mcp:write", + flow=OAuthFlow.DEVICE_CODE + ) + + tokens = await device_code_flow(config) + + # Use tokens for authenticated requests + headers = {'Authorization': f"Bearer {tokens['access_token']}"} + + async with streamablehttp_client( + "https://api.example.com/mcp", + headers=headers + ) as (read, write, session_info): + async with ClientSession(read, write) as session: + await session.initialize() + print("Device authenticated successfully") +``` + +## Integration examples + +### OAuth with connection pooling + +```python +""" +OAuth client with connection pooling for high-performance applications. +""" + +import asyncio +from dataclasses import dataclass +from typing import Dict, Optional +from contextlib import asynccontextmanager + +@dataclass +class AuthenticatedConnection: + """Connection with OAuth authentication.""" + read_stream: Any + write_stream: Any + session: ClientSession + tokens: Dict[str, Any] + +class OAuthConnectionPool: + """Connection pool with OAuth authentication.""" + + def __init__( + self, + server_url: str, + oauth_config: OAuthConfig, + pool_size: int = 5 + ): + self.server_url = server_url + self.oauth_config = oauth_config + self.pool_size = pool_size + self.available_connections: asyncio.Queue = asyncio.Queue() + self.active_connections: set = set() + self.oauth_client = AutoRefreshOAuthClient( + oauth_config, + FileTokenStore() + ) + + async def initialize(self): + """Initialize the connection pool.""" + # Authenticate once + await self.oauth_client.authenticate() + + # Create initial connections + for _ in range(self.pool_size): + connection = await self._create_connection() + if connection: + await self.available_connections.put(connection) + + async def _create_connection(self) -> Optional[AuthenticatedConnection]: + """Create an authenticated connection.""" + try: + # Get auth headers + headers = self.oauth_client.get_auth_headers() + + # Create connection with auth + read, write, session_info = await streamablehttp_client( + self.server_url, + headers=headers + ).__aenter__() + + # Initialize session + session = ClientSession(read, write) + await session.__aenter__() + await session.initialize() + + return AuthenticatedConnection( + read_stream=read, + write_stream=write, + session=session, + tokens=self.oauth_client._tokens + ) + + except Exception as e: + print(f"Failed to create connection: {e}") + return None + + @asynccontextmanager + async def get_connection(self): + """Get an authenticated connection from the pool.""" + try: + # Get available connection + connection = await asyncio.wait_for( + self.available_connections.get(), + timeout=10.0 + ) + + self.active_connections.add(connection) + yield connection.session + + except asyncio.TimeoutError: + raise RuntimeError("No connections available") + + finally: + # Return connection to pool + if connection in self.active_connections: + self.active_connections.remove(connection) + await self.available_connections.put(connection) + + async def close(self): + """Close all connections in the pool.""" + # Close active connections + for connection in list(self.active_connections): + try: + await connection.session.__aexit__(None, None, None) + except: + pass + + # Close available connections + while not self.available_connections.empty(): + try: + connection = self.available_connections.get_nowait() + await connection.session.__aexit__(None, None, None) + except: + pass + +# Usage example +async def pooled_oauth_example(): + """Example using OAuth connection pool.""" + config = load_oauth_config() + + pool = OAuthConnectionPool( + "https://api.example.com/mcp", + config, + pool_size=3 + ) + + await pool.initialize() + + try: + # Concurrent operations using pool + async def call_tool(tool_name: str, args: dict): + async with pool.get_connection() as session: + result = await session.call_tool(tool_name, args) + return result + + # Execute multiple authenticated calls concurrently + tasks = [ + call_tool("process_data", {"data": f"item_{i}"}) + for i in range(10) + ] + + results = await asyncio.gather(*tasks) + print(f"Processed {len(results)} requests") + + finally: + await pool.close() + +if __name__ == "__main__": + asyncio.run(pooled_oauth_example()) +``` + +## Testing OAuth clients + +### Mock OAuth server + +```python +""" +Mock OAuth server for testing OAuth clients. +""" + +import pytest +import asyncio +from aiohttp import web +import json + +class MockOAuthServer: + """Mock OAuth server for testing.""" + + def __init__(self, port: int = 9999): + self.port = port + self.app = web.Application() + self.runner = None + self.site = None + + # Setup routes + self.app.router.add_post('/oauth/token', self._handle_token) + self.app.router.add_get('/oauth/authorize', self._handle_authorize) + self.app.router.add_post('/device/code', self._handle_device_code) + + # Test data + self.valid_codes = set() + self.valid_tokens = set() + + async def start(self): + """Start the mock server.""" + self.runner = web.AppRunner(self.app) + await self.runner.setup() + self.site = web.TCPSite(self.runner, 'localhost', self.port) + await self.site.start() + + async def stop(self): + """Stop the mock server.""" + if self.runner: + await self.runner.cleanup() + + async def _handle_token(self, request): + """Handle token requests.""" + data = await request.post() + grant_type = data.get('grant_type') + + if grant_type == 'authorization_code': + code = data.get('code') + if code not in self.valid_codes: + return web.json_response( + {'error': 'invalid_grant'}, + status=400 + ) + + # Generate token + token = f"access_token_{len(self.valid_tokens)}" + self.valid_tokens.add(token) + + return web.json_response({ + 'access_token': token, + 'token_type': 'Bearer', + 'expires_in': 3600, + 'refresh_token': f"refresh_token_{len(self.valid_tokens)}" + }) + + elif grant_type == 'client_credentials': + client_id = data.get('client_id') + client_secret = data.get('client_secret') + + if client_id == 'test_client' and client_secret == 'test_secret': + token = f"service_token_{len(self.valid_tokens)}" + self.valid_tokens.add(token) + + return web.json_response({ + 'access_token': token, + 'token_type': 'Bearer', + 'expires_in': 3600 + }) + + return web.json_response({'error': 'unsupported_grant_type'}, status=400) + + async def _handle_authorize(self, request): + """Handle authorization requests.""" + # Generate and store auth code + auth_code = f"auth_code_{len(self.valid_codes)}" + self.valid_codes.add(auth_code) + + # Redirect with code + redirect_uri = request.query.get('redirect_uri') + state = request.query.get('state', '') + + redirect_url = f"{redirect_uri}?code={auth_code}&state={state}" + return web.Response( + status=302, + headers={'Location': redirect_url} + ) + + async def _handle_device_code(self, request): + """Handle device code requests.""" + return web.json_response({ + 'device_code': 'test_device_code', + 'user_code': 'TEST123', + 'verification_uri': f'http://localhost:{self.port}/device/verify', + 'verification_uri_complete': f'http://localhost:{self.port}/device/verify?code=TEST123', + 'expires_in': 1800, + 'interval': 1 + }) + +# Test fixtures +@pytest.fixture +async def mock_oauth_server(): + """Pytest fixture for mock OAuth server.""" + server = MockOAuthServer() + await server.start() + yield server + await server.stop() + +@pytest.mark.asyncio +async def test_oauth_client_credentials(mock_oauth_server): + """Test client credentials flow.""" + config = OAuthConfig( + client_id="test_client", + client_secret="test_secret", + token_url=f"http://localhost:{mock_oauth_server.port}/oauth/token", + flow=OAuthFlow.CLIENT_CREDENTIALS + ) + + tokens = await client_credentials_flow(config) + + assert 'access_token' in tokens + assert tokens['token_type'] == 'Bearer' + assert 'expires_in' in tokens + +@pytest.mark.asyncio +async def test_token_refresh(): + """Test automatic token refresh.""" + config = load_oauth_config() + token_store = FileTokenStore(".test_tokens.json") + + # Create client with mock tokens + client = AutoRefreshOAuthClient(config, token_store) + + # Test token validation and refresh logic + expired_tokens = { + 'access_token': 'expired_token', + 'refresh_token': 'valid_refresh', + 'expires_at': time.time() - 3600 # Expired 1 hour ago + } + + await token_store.save_tokens(expired_tokens) + + # This should trigger token refresh + await client.authenticate() + + # Cleanup + await token_store.clear_tokens() +``` + +## Best practices + +### Security guidelines + +- **Store secrets securely** - Use environment variables or secure vaults +- **Validate tokens** - Always validate token expiration and scope +- **Use PKCE** - Enable PKCE for public clients +- **Rotate tokens** - Implement proper token refresh +- **Secure storage** - Encrypt stored tokens when possible + +### Performance optimization + +- **Connection pooling** - Reuse authenticated connections +- **Token caching** - Cache valid tokens to avoid re-authentication +- **Async operations** - Use async/await for all OAuth operations +- **Batch requests** - Group multiple operations when possible +- **Monitor expiration** - Proactively refresh tokens before expiration + +### Error handling + +- **Retry logic** - Implement exponential backoff for token refresh +- **Graceful degradation** - Handle authentication failures gracefully +- **Logging** - Log authentication events for debugging +- **User feedback** - Provide clear error messages to users +- **Fallback strategies** - Have backup authentication methods + +## Next steps + +- **[Display utilities](display-utilities.md)** - UI helpers for OAuth flows +- **[Parsing results](parsing-results.md)** - Handle authenticated responses +- **[Writing clients](writing-clients.md)** - General client development patterns +- **[Authentication](authentication.md)** - Server-side authentication implementation \ No newline at end of file diff --git a/docs/parsing-results.md b/docs/parsing-results.md new file mode 100644 index 000000000..078a97925 --- /dev/null +++ b/docs/parsing-results.md @@ -0,0 +1,1333 @@ +# Parsing results + +Learn how to effectively parse and process complex results from MCP tools, resources, and prompts in your client applications. + +## Overview + +Result parsing enables: + +- **Structured data extraction** - Extract meaningful data from various response formats +- **Type-safe processing** - Validate and convert data to expected types +- **Error handling** - Gracefully handle malformed or unexpected responses +- **Content transformation** - Convert between different data formats + +## Basic result parsing + +### Tool result parsing + +```python +""" +Basic tool result parsing and validation. +""" + +from typing import Any, Dict, List, Optional, Union +from dataclasses import dataclass +import json +import re + +@dataclass +class ParsedToolResult: + """Structured representation of a tool result.""" + success: bool + content: List[str] + structured_data: Optional[Dict[str, Any]] = None + error_message: Optional[str] = None + metadata: Optional[Dict[str, Any]] = None + +class ToolResultParser: + """Parser for MCP tool results.""" + + def __init__(self): + self.content_extractors = { + 'text': self._extract_text_content, + 'json': self._extract_json_content, + 'data': self._extract_binary_content, + 'image': self._extract_image_content + } + + def parse_result(self, result) -> ParsedToolResult: + """Parse a tool result into structured format.""" + if not result: + return ParsedToolResult( + success=False, + content=[], + error_message="Empty result" + ) + + # Check for error status + is_error = getattr(result, 'isError', False) + + # Extract content + content_items = [] + structured_data = None + + if hasattr(result, 'content') and result.content: + for item in result.content: + content_type = self._determine_content_type(item) + extractor = self.content_extractors.get(content_type, self._extract_text_content) + + extracted = extractor(item) + if extracted: + content_items.append(extracted) + + # Extract structured content + if hasattr(result, 'structuredContent') and result.structuredContent: + structured_data = self._parse_structured_content(result.structuredContent) + + return ParsedToolResult( + success=not is_error, + content=content_items, + structured_data=structured_data, + error_message=content_items[0] if is_error and content_items else None + ) + + def _determine_content_type(self, item) -> str: + """Determine the type of content item.""" + if hasattr(item, 'text'): + return 'text' + elif hasattr(item, 'data'): + mime_type = getattr(item, 'mimeType', '') + if mime_type.startswith('image/'): + return 'image' + else: + return 'data' + else: + return 'text' + + def _extract_text_content(self, item) -> str: + """Extract text content from item.""" + if hasattr(item, 'text'): + return item.text + else: + return str(item) + + def _extract_json_content(self, item) -> str: + """Extract and validate JSON content.""" + text = self._extract_text_content(item) + try: + # Validate JSON + json.loads(text) + return text + except json.JSONDecodeError: + return text # Return as-is if not valid JSON + + def _extract_binary_content(self, item) -> str: + """Extract binary content information.""" + if hasattr(item, 'data'): + size = len(item.data) + mime_type = getattr(item, 'mimeType', 'application/octet-stream') + return f"Binary data: {size} bytes ({mime_type})" + return str(item) + + def _extract_image_content(self, item) -> str: + """Extract image content information.""" + if hasattr(item, 'data'): + size = len(item.data) + mime_type = getattr(item, 'mimeType', 'image/unknown') + return f"Image: {size} bytes ({mime_type})" + return str(item) + + def _parse_structured_content(self, structured) -> Dict[str, Any]: + """Parse structured content.""" + if isinstance(structured, dict): + return structured + elif isinstance(structured, str): + try: + return json.loads(structured) + except json.JSONDecodeError: + return {"raw": structured} + else: + return {"raw": str(structured)} + +# Usage example +async def basic_parsing_example(): + """Example of basic result parsing.""" + parser = ToolResultParser() + + # Simulate calling an MCP tool + async with streamablehttp_client("http://localhost:8000/mcp") as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + + # Call a tool + result = await session.call_tool("calculate", {"expression": "2 + 3"}) + + # Parse the result + parsed = parser.parse_result(result) + + print(f"Success: {parsed.success}") + print(f"Content: {parsed.content}") + if parsed.structured_data: + print(f"Structured: {parsed.structured_data}") + if parsed.error_message: + print(f"Error: {parsed.error_message}") + +if __name__ == "__main__": + import asyncio + asyncio.run(basic_parsing_example()) +``` + +## Advanced content extraction + +### Multi-format content parser + +```python +""" +Advanced content parser supporting multiple formats. +""" + +import base64 +import csv +import xml.etree.ElementTree as ET +from typing import Any, Dict, List, Optional, Tuple +from io import StringIO +import re +from dataclasses import dataclass + +@dataclass +class ExtractedContent: + """Represents extracted content with metadata.""" + content: Any + format: str + confidence: float + metadata: Dict[str, Any] + +class ContentExtractor: + """Advanced content extraction from MCP results.""" + + def __init__(self): + self.format_detectors = { + 'json': self._detect_json, + 'xml': self._detect_xml, + 'csv': self._detect_csv, + 'html': self._detect_html, + 'markdown': self._detect_markdown, + 'base64': self._detect_base64, + 'url': self._detect_url, + 'email': self._detect_email, + 'phone': self._detect_phone, + 'number': self._detect_number, + 'table': self._detect_table + } + + self.extractors = { + 'json': self._extract_json, + 'xml': self._extract_xml, + 'csv': self._extract_csv, + 'html': self._extract_html, + 'markdown': self._extract_markdown, + 'base64': self._extract_base64, + 'table': self._extract_table, + 'number': self._extract_number + } + + def extract_content(self, text: str) -> List[ExtractedContent]: + """Extract all recognizable content formats from text.""" + results = [] + + for format_name, detector in self.format_detectors.items(): + confidence = detector(text) + if confidence > 0.5: # Confidence threshold + extractor = self.extractors.get(format_name) + if extractor: + try: + content, metadata = extractor(text) + results.append(ExtractedContent( + content=content, + format=format_name, + confidence=confidence, + metadata=metadata + )) + except Exception as e: + # Log extraction error but continue + pass + + # Sort by confidence + results.sort(key=lambda x: x.confidence, reverse=True) + return results + + def _detect_json(self, text: str) -> float: + """Detect JSON content.""" + text = text.strip() + if (text.startswith('{') and text.endswith('}')) or \ + (text.startswith('[') and text.endswith(']')): + try: + json.loads(text) + return 0.95 + except json.JSONDecodeError: + return 0.1 + return 0.0 + + def _detect_xml(self, text: str) -> float: + """Detect XML content.""" + text = text.strip() + if text.startswith('<') and text.endswith('>'): + try: + ET.fromstring(text) + return 0.9 + except ET.ParseError: + return 0.1 + return 0.0 + + def _detect_csv(self, text: str) -> float: + """Detect CSV content.""" + lines = text.strip().split('\\n') + if len(lines) < 2: + return 0.0 + + # Check for consistent delimiter usage + delimiters = [',', ';', '\\t', '|'] + for delimiter in delimiters: + first_count = lines[0].count(delimiter) + if first_count > 0: + consistent = all( + line.count(delimiter) == first_count + for line in lines[1:3] # Check first few lines + ) + if consistent: + return 0.8 + + return 0.0 + + def _detect_html(self, text: str) -> float: + """Detect HTML content.""" + html_tags = re.findall(r'<[^>]+>', text) + if len(html_tags) > 0: + # Check for common HTML tags + common_tags = ['html', 'body', 'div', 'p', 'span', 'a', 'table', 'tr', 'td'] + tag_score = sum(1 for tag in html_tags if any(ct in tag.lower() for ct in common_tags)) + return min(0.9, tag_score / len(html_tags)) + return 0.0 + + def _detect_markdown(self, text: str) -> float: + """Detect Markdown content.""" + markdown_patterns = [ + r'^#{1,6} ', # Headers + r'\\*\\*.*?\\*\\*', # Bold + r'\\*.*?\\*', # Italic + r'`.*?`', # Code + r'^- ', # List items + r'^\\d+\\. ', # Numbered lists + r'\\[.*?\\]\\(.*?\\)' # Links + ] + + score = 0 + for pattern in markdown_patterns: + if re.search(pattern, text, re.MULTILINE): + score += 0.2 + + return min(0.9, score) + + def _detect_base64(self, text: str) -> float: + """Detect Base64 encoded content.""" + text = text.strip() + if len(text) % 4 == 0 and re.match(r'^[A-Za-z0-9+/]*={0,2}$', text): + try: + decoded = base64.b64decode(text) + # Check if decoded content looks valid + if len(decoded) > 0: + return 0.8 + except Exception: + pass + return 0.0 + + def _detect_url(self, text: str) -> float: + """Detect URL content.""" + url_pattern = r'https?://[^\\s]+' + urls = re.findall(url_pattern, text) + if urls: + return min(0.9, len(urls) * 0.3) + return 0.0 + + def _detect_email(self, text: str) -> float: + """Detect email addresses.""" + email_pattern = r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}' + emails = re.findall(email_pattern, text) + if emails: + return min(0.9, len(emails) * 0.4) + return 0.0 + + def _detect_phone(self, text: str) -> float: + """Detect phone numbers.""" + phone_patterns = [ + r'\\+?1?[-.\\s]?\\(?[0-9]{3}\\)?[-.\\s]?[0-9]{3}[-.\\s]?[0-9]{4}', # US format + r'\\+?[0-9]{1,4}[-.\\s]?[0-9]{3,4}[-.\\s]?[0-9]{3,4}[-.\\s]?[0-9]{3,4}' # International + ] + + for pattern in phone_patterns: + if re.search(pattern, text): + return 0.7 + return 0.0 + + def _detect_number(self, text: str) -> float: + """Detect numeric content.""" + # Remove whitespace and check if it's a number + clean_text = text.strip() + try: + float(clean_text) + return 0.8 + except ValueError: + # Check for numbers with units or formatting + number_pattern = r'[0-9.,]+' + numbers = re.findall(number_pattern, text) + if numbers and len(''.join(numbers)) / len(text) > 0.5: + return 0.6 + return 0.0 + + def _detect_table(self, text: str) -> float: + """Detect tabular data.""" + lines = text.strip().split('\\n') + if len(lines) < 2: + return 0.0 + + # Look for consistent column alignment + pipe_tables = all('|' in line for line in lines[:3]) + if pipe_tables: + return 0.8 + + # Look for whitespace-separated columns + consistent_spacing = True + first_parts = lines[0].split() + for line in lines[1:3]: + if len(line.split()) != len(first_parts): + consistent_spacing = False + break + + if consistent_spacing and len(first_parts) > 1: + return 0.7 + + return 0.0 + + def _extract_json(self, text: str) -> Tuple[Dict[str, Any], Dict[str, Any]]: + """Extract and parse JSON content.""" + data = json.loads(text.strip()) + metadata = { + 'keys': list(data.keys()) if isinstance(data, dict) else None, + 'length': len(data) if isinstance(data, (list, dict)) else None, + 'type': type(data).__name__ + } + return data, metadata + + def _extract_xml(self, text: str) -> Tuple[ET.Element, Dict[str, Any]]: + """Extract and parse XML content.""" + root = ET.fromstring(text.strip()) + metadata = { + 'root_tag': root.tag, + 'attributes': root.attrib, + 'children_count': len(list(root)), + 'text_content': root.text + } + return root, metadata + + def _extract_csv(self, text: str) -> Tuple[List[List[str]], Dict[str, Any]]: + """Extract and parse CSV content.""" + # Try different delimiters + delimiters = [',', ';', '\\t', '|'] + + for delimiter in delimiters: + try: + reader = csv.reader(StringIO(text), delimiter=delimiter) + rows = list(reader) + if len(rows) > 1 and len(rows[0]) > 1: + metadata = { + 'delimiter': delimiter, + 'rows': len(rows), + 'columns': len(rows[0]), + 'headers': rows[0] if rows else None + } + return rows, metadata + except Exception: + continue + + # Fallback: split by lines and whitespace + lines = text.strip().split('\\n') + rows = [line.split() for line in lines] + metadata = { + 'delimiter': 'whitespace', + 'rows': len(rows), + 'columns': len(rows[0]) if rows else 0 + } + return rows, metadata + + def _extract_html(self, text: str) -> Tuple[str, Dict[str, Any]]: + """Extract HTML content and metadata.""" + # Simple HTML parsing - extract text and tags + text_content = re.sub(r'<[^>]+>', '', text) + tags = re.findall(r'<([^>\\s]+)', text) + + metadata = { + 'tags': list(set(tags)), + 'tag_count': len(tags), + 'text_length': len(text_content), + 'has_links': 'href=' in text, + 'has_images': ' Tuple[str, Dict[str, Any]]: + """Extract Markdown content and structure.""" + headers = re.findall(r'^(#{1,6}) (.+)$', text, re.MULTILINE) + links = re.findall(r'\\[([^\\]]+)\\]\\(([^)]+)\\)', text) + code_blocks = re.findall(r'```([^`]+)```', text) + + metadata = { + 'headers': [(len(h[0]), h[1]) for h in headers], + 'links': [{'text': l[0], 'url': l[1]} for l in links], + 'code_blocks': len(code_blocks), + 'has_tables': '|' in text and '---' in text + } + return text, metadata + + def _extract_base64(self, text: str) -> Tuple[bytes, Dict[str, Any]]: + """Extract Base64 decoded content.""" + decoded = base64.b64decode(text.strip()) + + # Try to determine content type + content_type = 'binary' + if decoded.startswith(b'\\x89PNG'): + content_type = 'image/png' + elif decoded.startswith(b'\\xff\\xd8\\xff'): + content_type = 'image/jpeg' + elif decoded.startswith(b'%PDF'): + content_type = 'application/pdf' + + metadata = { + 'size': len(decoded), + 'content_type': content_type, + 'encoded_size': len(text) + } + return decoded, metadata + + def _extract_table(self, text: str) -> Tuple[List[List[str]], Dict[str, Any]]: + """Extract tabular data.""" + lines = text.strip().split('\\n') + + if '|' in text: + # Pipe-separated table + rows = [] + for line in lines: + if '|' in line: + cells = [cell.strip() for cell in line.split('|')] + # Remove empty cells at start/end + if cells and not cells[0]: + cells = cells[1:] + if cells and not cells[-1]: + cells = cells[:-1] + if cells: + rows.append(cells) + else: + # Whitespace-separated table + rows = [line.split() for line in lines if line.strip()] + + metadata = { + 'rows': len(rows), + 'columns': len(rows[0]) if rows else 0, + 'format': 'pipe' if '|' in text else 'whitespace' + } + return rows, metadata + + def _extract_number(self, text: str) -> Tuple[float, Dict[str, Any]]: + """Extract numeric value.""" + # Clean and parse number + clean_text = re.sub(r'[^0-9.-]', '', text.strip()) + + try: + if '.' in clean_text: + value = float(clean_text) + else: + value = int(clean_text) + except ValueError: + value = 0.0 + + # Extract unit if present + unit_match = re.search(r'([a-zA-Z%]+)\\s*$', text.strip()) + unit = unit_match.group(1) if unit_match else None + + metadata = { + 'original_text': text, + 'unit': unit, + 'type': 'float' if isinstance(value, float) else 'int' + } + return value, metadata + +# Usage example +def content_extraction_example(): + """Example of advanced content extraction.""" + extractor = ContentExtractor() + + # Sample mixed content + sample_text = ''' + Here's some JSON data: {"name": "John", "age": 30, "city": "New York"} + + And here's a CSV table: + Name,Age,City + Alice,25,Boston + Bob,30,Chicago + + Contact: john.doe@example.com or call +1-555-123-4567 + + Visit: https://example.com for more info + ''' + + # Extract all content formats + extracted = extractor.extract_content(sample_text) + + print("Extracted content:") + for item in extracted: + print(f"Format: {item.format} (confidence: {item.confidence:.2f})") + print(f"Content: {item.content}") + print(f"Metadata: {item.metadata}") + print("---") + +if __name__ == "__main__": + content_extraction_example() +``` + +## Type-safe result handling + +### Pydantic models for results + +```python +""" +Type-safe result handling using Pydantic models. +""" + +from pydantic import BaseModel, Field, validator +from typing import Any, Dict, List, Optional, Union, Literal +from datetime import datetime +import json + +class ContentItem(BaseModel): + """Base class for content items.""" + type: str + raw_content: str + +class TextContent(ContentItem): + """Text content item.""" + type: Literal["text"] = "text" + text: str + + @validator('text', pre=True) + def extract_text(cls, v, values): + if isinstance(v, str): + return v + return values.get('raw_content', str(v)) + +class JsonContent(ContentItem): + """JSON content item.""" + type: Literal["json"] = "json" + data: Dict[str, Any] + + @validator('data', pre=True) + def parse_json(cls, v, values): + if isinstance(v, dict): + return v + if isinstance(v, str): + try: + return json.loads(v) + except json.JSONDecodeError: + return {"raw": v} + return {"raw": str(v)} + +class BinaryContent(ContentItem): + """Binary content item.""" + type: Literal["binary"] = "binary" + size: int + mime_type: str = "application/octet-stream" + + @validator('size', pre=True) + def calculate_size(cls, v, values): + raw = values.get('raw_content', '') + if hasattr(raw, '__len__'): + return len(raw) + return 0 + +class TableContent(ContentItem): + """Table content item.""" + type: Literal["table"] = "table" + headers: List[str] + rows: List[List[str]] + + @validator('headers', 'rows', pre=True) + def parse_table(cls, v, values, field): + raw = values.get('raw_content', '') + if isinstance(raw, str): + lines = raw.strip().split('\\n') + if lines: + headers = lines[0].split(',') + rows = [line.split(',') for line in lines[1:]] + if field.name == 'headers': + return headers + else: + return rows + return v if isinstance(v, list) else [] + +class NumericContent(ContentItem): + """Numeric content item.""" + type: Literal["number"] = "number" + value: float + unit: Optional[str] = None + + @validator('value', pre=True) + def parse_number(cls, v, values): + if isinstance(v, (int, float)): + return float(v) + if isinstance(v, str): + # Extract number from string + import re + match = re.search(r'([+-]?\\d*\\.?\\d+)', v) + if match: + return float(match.group(1)) + return 0.0 + +class ErrorContent(ContentItem): + """Error content item.""" + type: Literal["error"] = "error" + message: str + code: Optional[str] = None + details: Optional[Dict[str, Any]] = None + +# Result models +class ToolResult(BaseModel): + """Typed tool execution result.""" + tool_name: str + success: bool + timestamp: datetime = Field(default_factory=datetime.now) + content: List[Union[TextContent, JsonContent, BinaryContent, TableContent, NumericContent, ErrorContent]] + structured_data: Optional[Dict[str, Any]] = None + execution_time: Optional[float] = None + metadata: Dict[str, Any] = Field(default_factory=dict) + +class ResourceResult(BaseModel): + """Typed resource read result.""" + uri: str + success: bool + timestamp: datetime = Field(default_factory=datetime.now) + content: List[Union[TextContent, JsonContent, BinaryContent]] + mime_type: Optional[str] = None + size: Optional[int] = None + metadata: Dict[str, Any] = Field(default_factory=dict) + +class PromptResult(BaseModel): + """Typed prompt result.""" + prompt_name: str + description: Optional[str] = None + messages: List[Dict[str, str]] + arguments: Dict[str, Any] = Field(default_factory=dict) + timestamp: datetime = Field(default_factory=datetime.now) + +# Parser for type-safe results +class TypeSafeParser: + """Parser that creates type-safe result objects.""" + + def __init__(self): + self.content_extractors = ContentExtractor() + + def parse_tool_result(self, tool_name: str, raw_result, execution_time: float = None) -> ToolResult: + """Parse raw tool result into typed model.""" + success = not getattr(raw_result, 'isError', False) + content_items = [] + + if hasattr(raw_result, 'content') and raw_result.content: + for item in raw_result.content: + content_items.extend(self._parse_content_item(item)) + + structured_data = None + if hasattr(raw_result, 'structuredContent') and raw_result.structuredContent: + structured_data = raw_result.structuredContent + if isinstance(structured_data, str): + try: + structured_data = json.loads(structured_data) + except json.JSONDecodeError: + pass + + return ToolResult( + tool_name=tool_name, + success=success, + content=content_items, + structured_data=structured_data, + execution_time=execution_time + ) + + def parse_resource_result(self, uri: str, raw_result) -> ResourceResult: + """Parse raw resource result into typed model.""" + content_items = [] + total_size = 0 + mime_type = None + + if hasattr(raw_result, 'contents') and raw_result.contents: + for item in raw_result.contents: + items = self._parse_content_item(item) + content_items.extend(items) + + # Extract metadata + if hasattr(item, 'data') and item.data: + total_size += len(item.data) + if hasattr(item, 'mimeType'): + mime_type = item.mimeType + + return ResourceResult( + uri=uri, + success=True, # If we got here, it succeeded + content=content_items, + mime_type=mime_type, + size=total_size + ) + + def parse_prompt_result(self, prompt_name: str, raw_result, arguments: Dict[str, Any] = None) -> PromptResult: + """Parse raw prompt result into typed model.""" + messages = [] + description = None + + if hasattr(raw_result, 'description'): + description = raw_result.description + + if hasattr(raw_result, 'messages') and raw_result.messages: + for msg in raw_result.messages: + if hasattr(msg, 'role') and hasattr(msg, 'content'): + content_text = msg.content.text if hasattr(msg.content, 'text') else str(msg.content) + messages.append({ + 'role': msg.role, + 'content': content_text + }) + + return PromptResult( + prompt_name=prompt_name, + description=description, + messages=messages, + arguments=arguments or {} + ) + + def _parse_content_item(self, item) -> List[Union[TextContent, JsonContent, BinaryContent, TableContent, NumericContent, ErrorContent]]: + """Parse a single content item into typed content.""" + raw_content = "" + + if hasattr(item, 'text'): + raw_content = item.text + elif hasattr(item, 'data'): + raw_content = item.data + else: + raw_content = str(item) + + # Extract different content types + extracted = self.content_extractors.extract_content(str(raw_content)) + + result = [] + for extract in extracted[:3]: # Limit to top 3 matches + try: + if extract.format == 'json': + result.append(JsonContent( + raw_content=raw_content, + data=extract.content + )) + elif extract.format == 'table': + if len(extract.content) > 0: + headers = extract.content[0] + rows = extract.content[1:] if len(extract.content) > 1 else [] + result.append(TableContent( + raw_content=raw_content, + headers=headers, + rows=rows + )) + elif extract.format == 'number': + result.append(NumericContent( + raw_content=raw_content, + value=extract.content, + unit=extract.metadata.get('unit') + )) + elif extract.format == 'base64': + result.append(BinaryContent( + raw_content=raw_content, + size=extract.metadata.get('size', 0), + mime_type=extract.metadata.get('content_type', 'application/octet-stream') + )) + except Exception: + # Fall back to text content + pass + + # Always include text representation + if not result or extracted[0].confidence < 0.8: + result.append(TextContent( + raw_content=raw_content, + text=str(raw_content) + )) + + return result + +# Usage example +async def type_safe_parsing_example(): + """Example of type-safe result parsing.""" + parser = TypeSafeParser() + + # Mock tool result + class MockResult: + def __init__(self, is_error=False): + self.isError = is_error + self.content = [MockContent()] + self.structuredContent = {"result": 42, "status": "success"} + + class MockContent: + def __init__(self): + self.text = '{"name": "John", "age": 30, "scores": [85, 92, 78]}' + + # Parse result + raw_result = MockResult() + parsed = parser.parse_tool_result("data_processor", raw_result, execution_time=0.5) + + print(f"Tool: {parsed.tool_name}") + print(f"Success: {parsed.success}") + print(f"Timestamp: {parsed.timestamp}") + print(f"Execution time: {parsed.execution_time}s") + + for i, content in enumerate(parsed.content): + print(f"\\nContent {i+1}:") + print(f" Type: {content.type}") + if isinstance(content, JsonContent): + print(f" Data: {content.data}") + elif isinstance(content, TextContent): + print(f" Text: {content.text}") + elif isinstance(content, NumericContent): + print(f" Value: {content.value} {content.unit or ''}") + + if parsed.structured_data: + print(f"\\nStructured data: {parsed.structured_data}") + +if __name__ == "__main__": + import asyncio + asyncio.run(type_safe_parsing_example()) +``` + +## Error handling and validation + +### Robust error handling + +```python +""" +Robust error handling for MCP result parsing. +""" + +from typing import Any, Dict, List, Optional, Tuple +from enum import Enum +import logging +from dataclasses import dataclass + +class ParseErrorType(Enum): + """Types of parsing errors.""" + INVALID_FORMAT = "invalid_format" + MISSING_CONTENT = "missing_content" + TYPE_MISMATCH = "type_mismatch" + VALIDATION_FAILED = "validation_failed" + UNKNOWN_ERROR = "unknown_error" + +@dataclass +class ParseError: + """Represents a parsing error.""" + error_type: ParseErrorType + message: str + field: Optional[str] = None + raw_value: Optional[Any] = None + suggestions: List[str] = None + +class ParseResult: + """Result of parsing operation with error handling.""" + + def __init__(self, success: bool = True): + self.success = success + self.data: Optional[Any] = None + self.errors: List[ParseError] = [] + self.warnings: List[str] = [] + + def add_error(self, error_type: ParseErrorType, message: str, field: str = None, raw_value: Any = None, suggestions: List[str] = None): + """Add a parsing error.""" + self.success = False + self.errors.append(ParseError( + error_type=error_type, + message=message, + field=field, + raw_value=raw_value, + suggestions=suggestions or [] + )) + + def add_warning(self, message: str): + """Add a parsing warning.""" + self.warnings.append(message) + + def set_data(self, data: Any): + """Set the parsed data.""" + self.data = data + +class RobustParser: + """Parser with comprehensive error handling.""" + + def __init__(self): + self.logger = logging.getLogger(__name__) + self.validation_rules = {} + + def register_validator(self, field_name: str, validator_func): + """Register a custom validator for a field.""" + self.validation_rules[field_name] = validator_func + + def parse_with_validation(self, data: Any, expected_schema: Dict[str, Any]) -> ParseResult: + """Parse data with validation against expected schema.""" + result = ParseResult() + + if not data: + result.add_error( + ParseErrorType.MISSING_CONTENT, + "No data provided", + suggestions=["Ensure the tool returned content"] + ) + return result + + try: + # Convert to dict if needed + if isinstance(data, str): + try: + data = json.loads(data) + except json.JSONDecodeError as e: + result.add_error( + ParseErrorType.INVALID_FORMAT, + f"Invalid JSON: {e}", + raw_value=data, + suggestions=["Check JSON syntax", "Verify proper escaping"] + ) + return result + + if not isinstance(data, dict): + result.add_error( + ParseErrorType.TYPE_MISMATCH, + f"Expected dict, got {type(data).__name__}", + raw_value=data + ) + return result + + # Validate schema + validated_data = self._validate_schema(data, expected_schema, result) + result.set_data(validated_data) + + except Exception as e: + self.logger.exception("Unexpected error during parsing") + result.add_error( + ParseErrorType.UNKNOWN_ERROR, + f"Unexpected error: {e}", + suggestions=["Check data format", "Verify tool output"] + ) + + return result + + def _validate_schema(self, data: Dict[str, Any], schema: Dict[str, Any], result: ParseResult) -> Dict[str, Any]: + """Validate data against schema.""" + validated = {} + + # Check required fields + required_fields = schema.get('required', []) + for field in required_fields: + if field not in data: + result.add_error( + ParseErrorType.MISSING_CONTENT, + f"Missing required field: {field}", + field=field, + suggestions=[f"Ensure tool returns '{field}' field"] + ) + + # Validate each field + properties = schema.get('properties', {}) + for field_name, field_schema in properties.items(): + if field_name in data: + validated_value = self._validate_field( + field_name, + data[field_name], + field_schema, + result + ) + if validated_value is not None: + validated[field_name] = validated_value + elif field_name in required_fields: + # Already handled above + pass + else: + # Optional field with default + default_value = field_schema.get('default') + if default_value is not None: + validated[field_name] = default_value + + # Check for unexpected fields + for field_name in data: + if field_name not in properties: + result.add_warning(f"Unexpected field: {field_name}") + validated[field_name] = data[field_name] # Include anyway + + return validated + + def _validate_field(self, field_name: str, value: Any, schema: Dict[str, Any], result: ParseResult) -> Any: + """Validate a single field.""" + expected_type = schema.get('type') + + # Type validation + if expected_type: + if not self._check_type(value, expected_type): + # Try type conversion + converted = self._convert_type(value, expected_type) + if converted is not None: + result.add_warning(f"Converted {field_name} from {type(value).__name__} to {expected_type}") + value = converted + else: + result.add_error( + ParseErrorType.TYPE_MISMATCH, + f"Field '{field_name}' expected {expected_type}, got {type(value).__name__}", + field=field_name, + raw_value=value, + suggestions=[f"Ensure tool returns {expected_type} for {field_name}"] + ) + return None + + # Range validation for numbers + if expected_type in ['number', 'integer'] and isinstance(value, (int, float)): + minimum = schema.get('minimum') + maximum = schema.get('maximum') + + if minimum is not None and value < minimum: + result.add_error( + ParseErrorType.VALIDATION_FAILED, + f"Field '{field_name}' value {value} below minimum {minimum}", + field=field_name, + raw_value=value + ) + return None + + if maximum is not None and value > maximum: + result.add_error( + ParseErrorType.VALIDATION_FAILED, + f"Field '{field_name}' value {value} above maximum {maximum}", + field=field_name, + raw_value=value + ) + return None + + # String length validation + if expected_type == 'string' and isinstance(value, str): + min_length = schema.get('minLength') + max_length = schema.get('maxLength') + + if min_length is not None and len(value) < min_length: + result.add_error( + ParseErrorType.VALIDATION_FAILED, + f"Field '{field_name}' length {len(value)} below minimum {min_length}", + field=field_name, + raw_value=value + ) + return None + + if max_length is not None and len(value) > max_length: + result.add_error( + ParseErrorType.VALIDATION_FAILED, + f"Field '{field_name}' length {len(value)} above maximum {max_length}", + field=field_name, + raw_value=value + ) + return None + + # Pattern validation + pattern = schema.get('pattern') + if pattern and isinstance(value, str): + import re + if not re.match(pattern, value): + result.add_error( + ParseErrorType.VALIDATION_FAILED, + f"Field '{field_name}' does not match pattern: {pattern}", + field=field_name, + raw_value=value, + suggestions=[f"Ensure {field_name} matches format: {pattern}"] + ) + return None + + # Enum validation + enum_values = schema.get('enum') + if enum_values and value not in enum_values: + result.add_error( + ParseErrorType.VALIDATION_FAILED, + f"Field '{field_name}' value '{value}' not in allowed values: {enum_values}", + field=field_name, + raw_value=value, + suggestions=[f"Use one of: {', '.join(map(str, enum_values))}"] + ) + return None + + # Custom validation + if field_name in self.validation_rules: + try: + custom_result = self.validation_rules[field_name](value) + if custom_result is not True: + result.add_error( + ParseErrorType.VALIDATION_FAILED, + f"Custom validation failed for '{field_name}': {custom_result}", + field=field_name, + raw_value=value + ) + return None + except Exception as e: + result.add_error( + ParseErrorType.VALIDATION_FAILED, + f"Custom validation error for '{field_name}': {e}", + field=field_name, + raw_value=value + ) + return None + + return value + + def _check_type(self, value: Any, expected_type: str) -> bool: + """Check if value matches expected type.""" + type_map = { + 'string': str, + 'number': (int, float), + 'integer': int, + 'boolean': bool, + 'array': list, + 'object': dict + } + + expected_python_type = type_map.get(expected_type) + if expected_python_type: + return isinstance(value, expected_python_type) + + return True # Unknown type, assume valid + + def _convert_type(self, value: Any, expected_type: str) -> Any: + """Attempt to convert value to expected type.""" + try: + if expected_type == 'string': + return str(value) + elif expected_type == 'number': + return float(value) + elif expected_type == 'integer': + return int(float(value)) # Handle string numbers + elif expected_type == 'boolean': + if isinstance(value, str): + return value.lower() in ('true', '1', 'yes', 'on') + return bool(value) + elif expected_type == 'array': + if isinstance(value, str): + # Try to parse as JSON array + return json.loads(value) + return list(value) + elif expected_type == 'object': + if isinstance(value, str): + return json.loads(value) + return dict(value) + except (ValueError, TypeError, json.JSONDecodeError): + pass + + return None + +# Usage example +def error_handling_example(): + """Example of robust error handling.""" + parser = RobustParser() + + # Register custom validator + def validate_email(value): + import re + pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$' + if re.match(pattern, value): + return True + return "Invalid email format" + + parser.register_validator('email', validate_email) + + # Define expected schema + schema = { + 'type': 'object', + 'required': ['name', 'age'], + 'properties': { + 'name': { + 'type': 'string', + 'minLength': 1, + 'maxLength': 50 + }, + 'age': { + 'type': 'integer', + 'minimum': 0, + 'maximum': 150 + }, + 'email': { + 'type': 'string' + }, + 'status': { + 'type': 'string', + 'enum': ['active', 'inactive', 'pending'] + } + } + } + + # Test with valid data + valid_data = { + 'name': 'John Doe', + 'age': 30, + 'email': 'john@example.com', + 'status': 'active' + } + + result = parser.parse_with_validation(valid_data, schema) + print(f"Valid data - Success: {result.success}") + if result.success: + print(f"Parsed data: {result.data}") + + # Test with invalid data + invalid_data = { + 'name': '', # Too short + 'age': '200', # Over maximum, but convertible + 'email': 'invalid-email', # Invalid format + 'status': 'unknown', # Not in enum + 'extra': 'field' # Unexpected field + } + + result = parser.parse_with_validation(invalid_data, schema) + print(f"\\nInvalid data - Success: {result.success}") + + for error in result.errors: + print(f"ERROR ({error.error_type.value}): {error.message}") + if error.field: + print(f" Field: {error.field}") + if error.suggestions: + print(f" Suggestions: {', '.join(error.suggestions)}") + + for warning in result.warnings: + print(f"WARNING: {warning}") + +if __name__ == "__main__": + error_handling_example() +``` + +## Best practices + +### Performance optimization + +- **Lazy parsing** - Parse content only when accessed +- **Caching** - Cache parsed results for repeated access +- **Streaming** - Process large results in chunks +- **Type hints** - Use type annotations for better IDE support +- **Validation limits** - Set reasonable limits for validation complexity + +### Error resilience + +- **Graceful degradation** - Fall back to text content when parsing fails +- **Detailed errors** - Provide specific error messages with suggestions +- **Partial parsing** - Extract what's possible even when some parts fail +- **Logging** - Log parsing issues for debugging +- **Recovery strategies** - Implement fallback parsing methods + +### Data integrity + +- **Schema validation** - Validate against expected schemas +- **Type checking** - Ensure data types match expectations +- **Range validation** - Check numeric ranges and string lengths +- **Format validation** - Validate specific formats like emails and URLs +- **Consistency checks** - Verify data consistency across fields + +## Next steps + +- **[OAuth for clients](oauth-clients.md)** - Secure authentication in clients +- **[Display utilities](display-utilities.md)** - Format parsed data for display +- **[Writing clients](writing-clients.md)** - Complete client development +- **[Low-level server](low-level-server.md)** - Understanding server responses \ No newline at end of file diff --git a/docs/progress-logging.md b/docs/progress-logging.md new file mode 100644 index 000000000..69ecb8bfa --- /dev/null +++ b/docs/progress-logging.md @@ -0,0 +1,226 @@ +# Progress & logging + +Learn how to implement comprehensive logging and progress reporting in your MCP servers to provide users with real-time feedback and debugging information. + +## Logging basics + +### Log levels and usage + +```python +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + +mcp = FastMCP("Logging Example") + +@mcp.tool() +async def demonstrate_logging(operation: str, ctx: Context[ServerSession, None]) -> str: + """Demonstrate different logging levels.""" + + # Debug: Detailed information for debugging + await ctx.debug(f"Starting operation: {operation}") + await ctx.debug("Initializing operation parameters") + + # Info: General information about operation progress + await ctx.info(f"Processing operation: {operation}") + + # Warning: Something unexpected but not critical + if operation == "risky_operation": + await ctx.warning("This operation has known limitations") + + # Error: Something went wrong + if operation == "failing_operation": + await ctx.error("Operation failed due to invalid input") + raise ValueError("Operation not supported") + + await ctx.info(f"Operation '{operation}' completed successfully") + return f"Completed: {operation}" + +@mcp.tool() +async def structured_logging( + data: dict, + ctx: Context[ServerSession, None] +) -> dict: + """Example of structured logging with context.""" + + operation_id = f"op_{hash(str(data)) % 10000:04d}" + + await ctx.info(f"[{operation_id}] Starting data processing") + await ctx.debug(f"[{operation_id}] Input data: {len(data)} fields") + + try: + # Simulate processing + processed_count = 0 + for key, value in data.items(): + await ctx.debug(f"[{operation_id}] Processing field: {key}") + processed_count += 1 + + await ctx.info(f"[{operation_id}] Processed {processed_count} fields successfully") + + return { + "operation_id": operation_id, + "status": "success", + "processed_fields": processed_count + } + + except Exception as e: + await ctx.error(f"[{operation_id}] Processing failed: {e}") + raise +``` + +## Progress reporting + +### Basic progress updates + +```python +import asyncio + +@mcp.tool() +async def long_running_task( + total_steps: int, + ctx: Context[ServerSession, None] +) -> str: + """Demonstrate basic progress reporting.""" + + await ctx.info(f"Starting task with {total_steps} steps") + + for i in range(total_steps): + # Simulate work + await asyncio.sleep(0.1) + + # Calculate progress + progress = (i + 1) / total_steps + + # Report progress + await ctx.report_progress( + progress=progress, + total=1.0, + message=f"Completed step {i + 1} of {total_steps}" + ) + + await ctx.debug(f"Step {i + 1} completed") + + await ctx.info("All steps completed") + return f"Successfully completed {total_steps} steps" + +@mcp.tool() +async def detailed_progress_task( + phases: list[str], + ctx: Context[ServerSession, None] +) -> dict: + """Multi-phase task with detailed progress reporting.""" + + total_phases = len(phases) + await ctx.info(f"Starting multi-phase task: {total_phases} phases") + + results = {} + + for phase_idx, phase_name in enumerate(phases): + await ctx.info(f"Starting phase {phase_idx + 1}/{total_phases}: {phase_name}") + + # Simulate phase work with sub-progress + phase_steps = 5 # Each phase has 5 steps + + for step in range(phase_steps): + # Simulate step work + await asyncio.sleep(0.05) + + # Calculate overall progress + completed_phases = phase_idx + phase_progress = (step + 1) / phase_steps + overall_progress = (completed_phases + phase_progress) / total_phases + + # Report progress with detailed message + await ctx.report_progress( + progress=overall_progress, + total=1.0, + message=f"Phase {phase_idx + 1}/{total_phases} ({phase_name}): Step {step + 1}/{phase_steps}" + ) + + await ctx.debug(f"Phase '{phase_name}' step {step + 1} completed") + + results[phase_name] = f"Completed in {phase_steps} steps" + await ctx.info(f"Phase '{phase_name}' completed") + + await ctx.info("All phases completed successfully") + + return { + "status": "completed", + "phases": results, + "total_phases": total_phases + } +``` + +### Advanced progress patterns + +```python +from typing import Callable, Awaitable + +async def progress_wrapper( + tasks: list[Callable[[], Awaitable[any]]], + task_names: list[str], + ctx: Context[ServerSession, None] +) -> list[any]: + """Execute multiple tasks with combined progress reporting.""" + + if len(tasks) != len(task_names): + raise ValueError("Tasks and names lists must have same length") + + await ctx.info(f"Executing {len(tasks)} tasks with progress tracking") + + results = [] + total_tasks = len(tasks) + + for i, (task, name) in enumerate(zip(tasks, task_names)): + await ctx.info(f"Starting task {i + 1}/{total_tasks}: {name}") + + try: + # Execute task + result = await task() + results.append(result) + + # Report completion + progress = (i + 1) / total_tasks + await ctx.report_progress( + progress=progress, + total=1.0, + message=f"Completed {i + 1}/{total_tasks}: {name}" + ) + + await ctx.info(f"Task '{name}' completed successfully") + + except Exception as e: + await ctx.error(f"Task '{name}' failed: {e}") + results.append(None) + + # Continue with next task + progress = (i + 1) / total_tasks + await ctx.report_progress( + progress=progress, + total=1.0, + message=f"Failed {i + 1}/{total_tasks}: {name} (continuing...)" + ) + + successful_tasks = sum(1 for r in results if r is not None) + await ctx.info(f"Completed {successful_tasks}/{total_tasks} tasks successfully") + + return results + +@mcp.tool() +async def batch_processing( + items: list[str], + ctx: Context[ServerSession, None] +) -> dict: + """Process items in batches with progress reporting.""" + + batch_size = 3 + total_items = len(items) + batches = [items[i:i + batch_size] for i in range(0, total_items, batch_size)] + total_batches = len(batches) + + await ctx.info(f"Processing {total_items} items in {total_batches} batches") + + processed_items = [] + failed_items = [] + + for batch_idx, batch in enumerate(batches): + await ctx.info(f"Processing batch {batch_idx + 1}/{total_batches} ({len(batch)} items)\")\n \n for item_idx, item in enumerate(batch):\n try:\n # Simulate item processing\n await asyncio.sleep(0.1)\n processed_items.append(f\"processed_{item}\")\n \n # Calculate detailed progress\n items_completed = len(processed_items) + len(failed_items)\n progress = items_completed / total_items\n \n await ctx.report_progress(\n progress=progress,\n total=1.0,\n message=f\"Batch {batch_idx + 1}/{total_batches}, Item {item_idx + 1}/{len(batch)}: {item}\"\n )\n \n await ctx.debug(f\"Successfully processed item: {item}\")\n \n except Exception as e:\n await ctx.warning(f\"Failed to process item '{item}': {e}\")\n failed_items.append(item)\n \n await ctx.info(f\"Batch {batch_idx + 1} completed\")\n \n await ctx.info(f\"Processing complete: {len(processed_items)} successful, {len(failed_items)} failed\")\n \n return {\n \"total_items\": total_items,\n \"processed_count\": len(processed_items),\n \"failed_count\": len(failed_items),\n \"processed_items\": processed_items,\n \"failed_items\": failed_items\n }\n```\n\n## Custom logging patterns\n\n### Contextual logging\n\n```python\nfrom dataclasses import dataclass\nfrom datetime import datetime\n\n@dataclass\nclass LogContext:\n \"\"\"Context information for enhanced logging.\"\"\"\n user_id: str | None = None\n session_id: str | None = None\n operation_id: str | None = None\n timestamp: datetime | None = None\n\nclass EnhancedLogger:\n \"\"\"Enhanced logger with context management.\"\"\"\n \n def __init__(self, ctx: Context):\n self.ctx = ctx\n self.log_context = LogContext()\n \n def set_context(self, **kwargs):\n \"\"\"Update logging context.\"\"\"\n for key, value in kwargs.items():\n if hasattr(self.log_context, key):\n setattr(self.log_context, key, value)\n \n async def log_with_context(self, level: str, message: str):\n \"\"\"Log message with context information.\"\"\"\n context_parts = []\n \n if self.log_context.user_id:\n context_parts.append(f\"user:{self.log_context.user_id}\")\n if self.log_context.session_id:\n context_parts.append(f\"session:{self.log_context.session_id}\")\n if self.log_context.operation_id:\n context_parts.append(f\"op:{self.log_context.operation_id}\")\n \n context_str = \"[\" + \",\".join(context_parts) + \"]\" if context_parts else \"\"\n full_message = f\"{context_str} {message}\" if context_str else message\n \n # Use appropriate log level\n if level == \"debug\":\n await self.ctx.debug(full_message)\n elif level == \"info\":\n await self.ctx.info(full_message)\n elif level == \"warning\":\n await self.ctx.warning(full_message)\n elif level == \"error\":\n await self.ctx.error(full_message)\n else:\n await self.ctx.log(level, full_message)\n\n@mcp.tool()\nasync def contextual_operation(\n user_id: str,\n data: dict,\n ctx: Context[ServerSession, None]\n) -> dict:\n \"\"\"Operation with contextual logging.\"\"\"\n \n # Set up enhanced logger\n logger = EnhancedLogger(ctx)\n logger.set_context(\n user_id=user_id,\n session_id=ctx.request_id[:8],\n operation_id=f\"ctx_op_{hash(user_id) % 1000:03d}\",\n timestamp=datetime.now()\n )\n \n await logger.log_with_context(\"info\", \"Starting contextual operation\")\n \n try:\n # Process data with contextual logging\n await logger.log_with_context(\"debug\", f\"Processing {len(data)} data fields\")\n \n processed_data = {}\n for key, value in data.items():\n await logger.log_with_context(\"debug\", f\"Processing field: {key}\")\n processed_data[key] = f\"processed_{value}\"\n \n await logger.log_with_context(\"info\", \"Operation completed successfully\")\n \n return {\n \"status\": \"success\",\n \"user_id\": user_id,\n \"processed_fields\": len(processed_data),\n \"operation_id\": logger.log_context.operation_id\n }\n \n except Exception as e:\n await logger.log_with_context(\"error\", f\"Operation failed: {e}\")\n raise\n```\n\n### Performance logging\n\n```python\nimport time\nfrom functools import wraps\n\ndef performance_logged(func):\n \"\"\"Decorator to add performance logging to tools.\"\"\"\n \n @wraps(func)\n async def wrapper(*args, **kwargs):\n # Find context in arguments\n ctx = None\n for arg in args:\n if isinstance(arg, Context):\n ctx = arg\n break\n \n if not ctx:\n return await func(*args, **kwargs)\n \n # Start timing\n start_time = time.time()\n function_name = func.__name__\n \n await ctx.info(f\"[PERF] Starting {function_name}\")\n await ctx.debug(f\"[PERF] {function_name} called with {len(args)} args\")\n \n try:\n result = await func(*args, **kwargs)\n \n # Log success with timing\n duration = time.time() - start_time\n await ctx.info(f\"[PERF] {function_name} completed in {duration:.3f}s\")\n \n if duration > 5.0: # Warn about slow operations\n await ctx.warning(f\"[PERF] Slow operation detected: {function_name} took {duration:.3f}s\")\n \n return result\n \n except Exception as e:\n # Log failure with timing\n duration = time.time() - start_time\n await ctx.error(f\"[PERF] {function_name} failed after {duration:.3f}s: {e}\")\n raise\n \n return wrapper\n\n@mcp.tool()\n@performance_logged\nasync def performance_monitored_task(\n complexity: str,\n ctx: Context[ServerSession, None]\n) -> dict:\n \"\"\"Task with automatic performance monitoring.\"\"\"\n \n # Simulate different complexity levels\n if complexity == \"light\":\n await asyncio.sleep(0.1)\n operations = 10\n elif complexity == \"medium\":\n await asyncio.sleep(1.0)\n operations = 100\n elif complexity == \"heavy\":\n await asyncio.sleep(3.0)\n operations = 1000\n else:\n await asyncio.sleep(0.05)\n operations = 5\n \n return {\n \"complexity\": complexity,\n \"operations_performed\": operations,\n \"status\": \"completed\"\n }\n```\n\n## Error logging and debugging\n\n### Comprehensive error handling\n\n```python\nimport traceback\nfrom typing import Any\n\n@mcp.tool()\nasync def robust_operation(\n operation_type: str,\n parameters: dict[str, Any],\n ctx: Context[ServerSession, None]\n) -> dict:\n \"\"\"Operation with comprehensive error logging.\"\"\"\n \n operation_id = f\"rob_{hash(operation_type) % 1000:03d}\"\n \n await ctx.info(f\"[{operation_id}] Starting robust operation: {operation_type}\")\n await ctx.debug(f\"[{operation_id}] Parameters: {parameters}\")\n \n try:\n # Validate parameters\n if not isinstance(parameters, dict):\n raise ValueError(\"Parameters must be a dictionary\")\n \n await ctx.debug(f\"[{operation_id}] Parameter validation passed\")\n \n # Simulate operation based on type\n if operation_type == \"process_data\":\n if \"data\" not in parameters:\n raise KeyError(\"Missing required parameter: data\")\n \n data = parameters[\"data\"]\n await ctx.info(f\"[{operation_id}] Processing {len(data) if hasattr(data, '__len__') else 'unknown size'} data\")\n \n # Simulate processing with potential failures\n if data == \"invalid_data\":\n raise ValueError(\"Invalid data format detected\")\n \n result = f\"Processed: {data}\"\n \n elif operation_type == \"network_call\":\n url = parameters.get(\"url\")\n if not url:\n raise ValueError(\"URL parameter required for network_call\")\n \n await ctx.info(f\"[{operation_id}] Making network call to: {url}\")\n \n # Simulate network issues\n if \"error\" in url:\n raise ConnectionError(f\"Failed to connect to {url}\")\n \n result = f\"Response from {url}\"\n \n else:\n raise NotImplementedError(f\"Operation type '{operation_type}' not supported\")\n \n await ctx.info(f\"[{operation_id}] Operation completed successfully\")\n \n return {\n \"operation_id\": operation_id,\n \"status\": \"success\",\n \"result\": result,\n \"operation_type\": operation_type\n }\n \n except KeyError as e:\n await ctx.error(f\"[{operation_id}] Missing parameter: {e}\")\n await ctx.debug(f\"[{operation_id}] Available parameters: {list(parameters.keys())}\")\n \n return {\n \"operation_id\": operation_id,\n \"status\": \"error\",\n \"error_type\": \"missing_parameter\",\n \"error_message\": str(e)\n }\n \n except ValueError as e:\n await ctx.error(f\"[{operation_id}] Invalid parameter value: {e}\")\n await ctx.debug(f\"[{operation_id}] Parameter validation failed\")\n \n return {\n \"operation_id\": operation_id,\n \"status\": \"error\",\n \"error_type\": \"invalid_parameter\",\n \"error_message\": str(e)\n }\n \n except Exception as e:\n # Log full exception details\n await ctx.error(f\"[{operation_id}] Unexpected error: {e}\")\n await ctx.debug(f\"[{operation_id}] Full traceback: {traceback.format_exc()}\")\n \n return {\n \"operation_id\": operation_id,\n \"status\": \"error\",\n \"error_type\": \"unexpected_error\",\n \"error_message\": str(e)\n }\n```\n\n## Notifications and resource updates\n\n### Resource change notifications\n\n```python\n@mcp.resource(\"status://{service}\")\ndef get_service_status(service: str) -> str:\n \"\"\"Get status of a service.\"\"\"\n # Simulate service status\n statuses = {\n \"database\": \"operational\",\n \"api\": \"degraded\",\n \"cache\": \"maintenance\"\n }\n return f\"Service '{service}' status: {statuses.get(service, 'unknown')}\"\n\n@mcp.tool()\nasync def update_service_status(\n service: str,\n new_status: str,\n ctx: Context[ServerSession, None]\n) -> dict:\n \"\"\"Update service status and notify clients.\"\"\"\n \n await ctx.info(f\"Updating {service} status to: {new_status}\")\n \n # Update status (in a real app, this would update a database)\n # statuses[service] = new_status\n \n # Notify clients about the resource change\n resource_uri = f\"status://{service}\"\n await ctx.session.send_resource_updated(resource_uri)\n \n await ctx.info(f\"Status update notification sent for {service}\")\n \n return {\n \"service\": service,\n \"new_status\": new_status,\n \"notification_sent\": True\n }\n\n@mcp.tool()\nasync def bulk_status_update(\n updates: dict[str, str],\n ctx: Context[ServerSession, None]\n) -> dict:\n \"\"\"Update multiple service statuses.\"\"\"\n \n await ctx.info(f\"Starting bulk update for {len(updates)} services\")\n \n updated_services = []\n \n for service, status in updates.items():\n try:\n await ctx.debug(f\"Updating {service} to {status}\")\n \n # Update status\n # statuses[service] = status\n \n # Send individual resource update\n await ctx.session.send_resource_updated(f\"status://{service}\")\n \n updated_services.append(service)\n \n except Exception as e:\n await ctx.warning(f\"Failed to update {service}: {e}\")\n \n # Notify that the overall resource list may have changed\n await ctx.session.send_resource_list_changed()\n \n await ctx.info(f\"Bulk update completed: {len(updated_services)} services updated\")\n \n return {\n \"total_updates\": len(updates),\n \"successful_updates\": len(updated_services),\n \"updated_services\": updated_services\n }\n```\n\n## Testing logging and progress\n\n### Unit testing with log verification\n\n```python\nimport pytest\nfrom unittest.mock import AsyncMock, Mock\n\n@pytest.mark.asyncio\nasync def test_logging_functionality():\n \"\"\"Test that logging works correctly.\"\"\"\n \n # Mock context with logging methods\n mock_ctx = Mock()\n mock_ctx.info = AsyncMock()\n mock_ctx.debug = AsyncMock()\n mock_ctx.warning = AsyncMock()\n mock_ctx.error = AsyncMock()\n \n # Test the logging function\n result = await demonstrate_logging(\"test_operation\", mock_ctx)\n \n # Verify logging calls were made\n mock_ctx.debug.assert_called()\n mock_ctx.info.assert_called()\n assert \"test_operation\" in str(result)\n\n@pytest.mark.asyncio\nasync def test_progress_reporting():\n \"\"\"Test progress reporting functionality.\"\"\"\n \n mock_ctx = Mock()\n mock_ctx.info = AsyncMock()\n mock_ctx.debug = AsyncMock()\n mock_ctx.report_progress = AsyncMock()\n \n # Test progress function\n result = await long_running_task(3, mock_ctx)\n \n # Verify progress was reported\n assert mock_ctx.report_progress.call_count == 3\n \n # Check progress values\n calls = mock_ctx.report_progress.call_args_list\n assert calls[0][1]['progress'] == 1/3 # First progress report\n assert calls[1][1]['progress'] == 2/3 # Second progress report\n assert calls[2][1]['progress'] == 1.0 # Final progress report\n```\n\n## Best practices\n\n### Logging guidelines\n\n- **Appropriate levels** - Use debug for detailed info, info for general progress, warning for issues, error for failures\n- **Structured messages** - Include operation IDs and context information\n- **Performance awareness** - Log timing information for slow operations\n- **Error details** - Include full error context without exposing sensitive data\n\n### Progress reporting best practices\n\n- **Frequent updates** - Update progress regularly but not excessively\n- **Meaningful messages** - Provide clear descriptions of current activity\n- **Accurate percentages** - Ensure progress values are accurate and monotonic\n- **Error handling** - Continue reporting progress even when some operations fail\n\n### Performance considerations\n\n- **Async logging** - Use async logging methods to avoid blocking\n- **Log levels** - Filter logs appropriately in production\n- **Batch operations** - Group related log messages when possible\n- **Resource cleanup** - Clean up progress tracking resources\n\n## Next steps\n\n- **[Context patterns](context.md)** - Advanced context usage for logging\n- **[Authentication](authentication.md)** - Security logging and audit trails\n- **[Error handling](tools.md#error-handling-and-validation)** - Comprehensive error handling patterns\n- **[Performance optimization](servers.md#performance-considerations)** - Server performance monitoring \ No newline at end of file diff --git a/docs/prompts.md b/docs/prompts.md new file mode 100644 index 000000000..44548c6a0 --- /dev/null +++ b/docs/prompts.md @@ -0,0 +1,562 @@ +# Prompts + +Prompts are reusable templates that help structure LLM interactions. They provide a standardized way to request specific types of responses from LLMs. + +## What are prompts? + +Prompts in MCP are: + +- **Templates** - Reusable patterns for LLM interactions +- **User-controlled** - Invoked by user choice, not automatically by LLMs +- **Parameterized** - Accept arguments to customize the prompt +- **Structured** - Can include multiple messages and roles + +## Basic prompt creation + +### Simple prompts + +```python +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("Prompt Examples") + +@mcp.prompt() +def write_email(recipient: str, subject: str, tone: str = "professional") -> str: + """Generate an email writing prompt.""" + return f"""Please write an email to {recipient} with the subject "{subject}". + +Use a {tone} tone and include: +- A clear purpose +- Appropriate greeting and closing +- Professional formatting +""" + +@mcp.prompt() +def code_review(language: str, code_snippet: str) -> str: + """Generate a code review prompt.""" + return f"""Please review this {language} code: + +```{language} +{code_snippet} +``` + +Focus on: +- Code quality and best practices +- Potential bugs or issues +- Performance considerations +- Readability and maintainability +""" +``` + +### Prompts with titles + +```python +@mcp.prompt(title="Creative Writing Assistant") +def creative_writing(genre: str, theme: str, length: str = "short") -> str: + """Generate a creative writing prompt.""" + return f"""Write a {length} {genre} story incorporating the theme of "{theme}". + +Guidelines: +- Create compelling characters +- Build tension and conflict +- Include vivid descriptions +- Provide a satisfying resolution +""" + +@mcp.prompt(title="Technical Documentation Helper") +def tech_docs(feature: str, audience: str = "developers") -> str: + """Generate a technical documentation prompt.""" + return f"""Create comprehensive documentation for the "{feature}" feature. + +Target audience: {audience} + +Include: +- Clear overview and purpose +- Step-by-step usage instructions +- Code examples where applicable +- Common troubleshooting scenarios +- Best practices and tips +""" +``` + +## Advanced prompt patterns + +### Multi-message prompts + +```python +from mcp.server.fastmcp.prompts import base + +@mcp.prompt(title="Interview Preparation") +def interview_prep(role: str, company: str, experience_level: str) -> list[base.Message]: + """Generate an interview preparation conversation.""" + return [ + base.UserMessage( + f"I'm preparing for a {role} interview at {company}. " + f"I have {experience_level} level experience." + ), + base.AssistantMessage( + "I'll help you prepare! Let me start with some key questions " + "you should be ready to answer:" + ), + base.UserMessage( + "What are the most important technical concepts I should review?" + ) + ] + +@mcp.prompt(title="Debugging Session") +def debug_session( + error_message: str, + language: str, + context: str = "web application" +) -> list[base.Message]: + """Create a debugging conversation prompt.""" + return [ + base.UserMessage( + f"I'm getting this error in my {language} {context}:" + ), + base.UserMessage(error_message), + base.AssistantMessage( + "Let me help you debug this. First, let's understand the context better." + ), + base.UserMessage( + "What additional information do you need to help solve this?" + ) + ] +``` + +### Context-aware prompts + +```python +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + +mcp = FastMCP("Context-Aware Prompts") + +@mcp.prompt() +async def personalized_learning( + topic: str, + difficulty: str, + ctx: Context[ServerSession, None] +) -> str: + """Generate a learning prompt customized to the user.""" + # In a real application, you might fetch user preferences + user_id = getattr(ctx.session, 'user_id', 'anonymous') + + await ctx.info(f"Creating learning prompt for user {user_id}") + + return f"""Create a {difficulty} level learning plan for: {topic} + +Customize the approach based on: +- Learning style: visual and hands-on preferred +- Time available: 30-45 minutes per session +- Goal: practical application of concepts + +Structure: +1. Key concepts overview +2. Step-by-step learning path +3. Practical exercises +4. Resources for deeper learning +""" + +@mcp.prompt() +async def project_analysis( + project_type: str, + requirements: str, + ctx: Context[ServerSession, None] +) -> str: + """Generate a project analysis prompt with server context.""" + server_name = ctx.fastmcp.name + + return f"""As an expert analyst working with {server_name}, please analyze this {project_type} project: + +Requirements: +{requirements} + +Provide: +1. Technical feasibility assessment +2. Resource requirements estimation +3. Timeline and milestone suggestions +4. Risk analysis and mitigation strategies +5. Technology stack recommendations +""" +``` + +### Data-driven prompts + +```python +from datetime import datetime +import json + +@mcp.prompt() +def daily_standup(team_member: str, yesterday_tasks: list[str]) -> str: + """Generate a daily standup prompt.""" + today = datetime.now().strftime("%Y-%m-%d") + + tasks_summary = "\\n".join(f"- {task}" for task in yesterday_tasks) + + return f"""Daily Standup for {team_member} - {today} + +Yesterday's completed tasks: +{tasks_summary} + +Please provide your standup update covering: + +1. **What did you accomplish yesterday?** + (Reference the tasks above and any additional work) + +2. **What are you planning to work on today?** + (List your priorities and focus areas) + +3. **Are there any blockers or impediments?** + (Identify anything that might slow down progress) + +4. **Do you need help from the team?** + (Mention any collaboration or support needed) +""" + +@mcp.prompt() +def code_optimization( + language: str, + performance_metrics: dict[str, float], + code_section: str +) -> str: + """Generate a code optimization prompt with performance data.""" + metrics_text = "\\n".join( + f"- {metric}: {value}" for metric, value in performance_metrics.items() + ) + + return f"""Optimize this {language} code based on performance analysis: + +Current Performance Metrics: +{metrics_text} + +Code to optimize: +```{language} +{code_section} +``` + +Focus on: +1. Identifying performance bottlenecks +2. Suggesting specific optimizations +3. Explaining the reasoning behind each suggestion +4. Estimating performance impact +5. Maintaining code readability and maintainability + +Provide optimized code with detailed explanations. +""" +``` + +## Prompt composition patterns + +### Modular prompts + +```python +def get_writing_guidelines(tone: str) -> str: + """Get writing guidelines based on tone.""" + guidelines = { + "professional": "Use formal language, clear structure, and avoid colloquialisms", + "casual": "Use conversational language, contractions, and a friendly approach", + "academic": "Use precise terminology, citations, and formal academic structure", + "creative": "Use vivid imagery, varied sentence structure, and engaging language" + } + return guidelines.get(tone, guidelines["professional"]) + +def get_length_instructions(length: str) -> str: + """Get length-specific instructions.""" + instructions = { + "brief": "Keep it concise - aim for 1-2 paragraphs maximum", + "medium": "Provide moderate detail - aim for 3-5 paragraphs", + "detailed": "Be comprehensive - provide thorough analysis and examples", + "comprehensive": "Include all relevant information - create a complete reference" + } + return instructions.get(length, instructions["medium"]) + +@mcp.prompt(title="Modular Content Generator") +def generate_content( + topic: str, + content_type: str, + tone: str = "professional", + length: str = "medium" +) -> str: + """Generate content using modular prompt components.""" + writing_guidelines = get_writing_guidelines(tone) + length_instructions = get_length_instructions(length) + + return f"""Create {content_type} content about: {topic} + +Writing Guidelines: +{writing_guidelines} + +Length Requirements: +{length_instructions} + +Structure your response with: +1. Engaging opening +2. Well-organized main content +3. Clear conclusion or call-to-action + +Additional requirements: +- Use appropriate headings and formatting +- Include relevant examples where helpful +- Ensure accuracy and credibility +""" +``` + +### Conditional prompts + +```python +@mcp.prompt() +def learning_assessment( + subject: str, + current_level: str, + learning_goals: list[str], + time_available: str +) -> str: + """Generate learning prompts based on user level and goals.""" + + # Customize based on current level + if current_level.lower() == "beginner": + approach = """ + Start with fundamental concepts and basic terminology. + Use simple examples and step-by-step explanations. + Focus on building a solid foundation before advanced topics. + """ + elif current_level.lower() == "intermediate": + approach = """ + Build on existing knowledge with more complex scenarios. + Include real-world applications and case studies. + Challenge assumptions and introduce advanced concepts. + """ + else: # advanced + approach = """ + Dive deep into expert-level concepts and edge cases. + Explore cutting-edge developments and research. + Focus on optimization, best practices, and innovation. + """ + + # Customize based on time available + if "week" in time_available.lower(): + timeline = "Create a week-long intensive learning plan" + elif "month" in time_available.lower(): + timeline = "Design a month-long comprehensive curriculum" + else: + timeline = "Structure for flexible, self-paced learning" + + goals_text = "\\n".join(f"- {goal}" for goal in learning_goals) + + return f"""Create a personalized {subject} learning plan: + +Current Level: {current_level} +Learning Goals: +{goals_text} + +Time Frame: {time_available} + +Learning Approach: +{approach} + +Planning Instructions: +{timeline} + +Include: +1. Learning path and milestones +2. Recommended resources and materials +3. Practice exercises and projects +4. Progress assessment methods +5. Tips for overcoming common challenges +""" +``` + +## Integration with other MCP features + +### Prompts that reference resources + +```python +@mcp.resource("documentation://{section}") +def get_documentation(section: str) -> str: + """Get documentation for a specific section.""" + docs = { + "api": "API Documentation: Use GET /users for user list...", + "setup": "Setup Guide: Install dependencies with npm install...", + "troubleshooting": "Troubleshooting: Common issues and solutions..." + } + return docs.get(section, "Documentation section not found") + +@mcp.prompt() +def help_with_documentation(section: str, specific_question: str) -> str: + """Generate a prompt that references documentation resources.""" + return f"""I need help with the {section} documentation. + +Specific question: {specific_question} + +Please: +1. Read the documentation resource: documentation://{section} +2. Answer my specific question based on the documentation +3. Provide additional context or examples if helpful +4. Suggest related documentation sections if relevant + +If the documentation doesn't fully answer my question, please: +- Explain what information is available +- Suggest alternative approaches +- Recommend additional resources +""" +``` + +### Prompts for tool workflows + +```python +@mcp.prompt() +def data_analysis_workflow( + data_source: str, + analysis_type: str, + output_format: str = "report" +) -> str: + """Generate a prompt for data analysis using available tools.""" + return f"""Perform a comprehensive data analysis workflow: + +Data Source: {data_source} +Analysis Type: {analysis_type} +Output Format: {output_format} + +Workflow steps: +1. Use the `load_data` tool to import data from {data_source} +2. Use the `analyze_data` tool to perform {analysis_type} analysis +3. Use the `visualize_results` tool to create appropriate charts +4. Use the `generate_report` tool to create a {output_format} + +For each step: +- Explain the rationale for your approach +- Describe any insights or patterns discovered +- Note any data quality issues or limitations +- Suggest next steps or follow-up analyses + +Provide a complete analysis with actionable insights. +""" +``` + +## Testing prompts + +### Unit testing + +```python +import pytest +from mcp.server.fastmcp import FastMCP + +def test_simple_prompt(): + mcp = FastMCP("Test") + + @mcp.prompt() + def test_prompt(name: str) -> str: + return f"Hello, {name}!" + + result = test_prompt("World") + assert "Hello, World!" in result + +def test_parameterized_prompt(): + mcp = FastMCP("Test") + + @mcp.prompt() + def email_prompt(recipient: str, tone: str = "professional") -> str: + return f"Write a {tone} email to {recipient}" + + result = email_prompt("Alice", "friendly") + assert "friendly" in result + assert "Alice" in result + +def test_multi_message_prompt(): + mcp = FastMCP("Test") + + @mcp.prompt() + def conversation() -> list: + return [ + {"role": "user", "text": "Hello"}, + {"role": "assistant", "text": "Hi there!"} + ] + + result = conversation() + assert len(result) == 2 + assert result[0]["role"] == "user" +``` + +### Prompt validation + +```python +def validate_prompt_output(prompt_result): + """Validate prompt output structure.""" + if isinstance(prompt_result, str): + assert len(prompt_result.strip()) > 0, "Prompt should not be empty" + assert prompt_result.count("\\n") <= 50, "Prompt should not be excessively long" + elif isinstance(prompt_result, list): + assert len(prompt_result) > 0, "Multi-message prompt should have messages" + for message in prompt_result: + assert "role" in message or hasattr(message, "role"), "Messages need roles" + +@pytest.mark.parametrize("tone,recipient", [ + ("professional", "manager"), + ("casual", "colleague"), + ("formal", "client") +]) +def test_email_prompt_variations(tone, recipient): + mcp = FastMCP("Test") + + @mcp.prompt() + def email_prompt(recipient: str, tone: str) -> str: + return f"Write a {tone} email to {recipient}" + + result = email_prompt(recipient, tone) + validate_prompt_output(result) + assert tone in result + assert recipient in result +``` + +## Best practices + +### Design principles + +- **Clear purpose** - Each prompt should have a specific, well-defined goal +- **Flexible parameters** - Allow customization while maintaining structure +- **Comprehensive instructions** - Provide clear guidance for the LLM +- **Consistent format** - Use similar patterns across related prompts + +### Content guidelines + +- **Specific instructions** - Be explicit about what you want +- **Context provision** - Include relevant background information +- **Output specification** - Describe the expected response format +- **Examples inclusion** - Show examples when helpful + +### User experience + +- **Descriptive names** - Use clear, descriptive prompt names +- **Helpful descriptions** - Provide good docstrings +- **Sensible defaults** - Choose reasonable default parameter values +- **Progressive complexity** - Start simple, add complexity as needed + +## Common use cases + +### Content creation prompts +- Writing assistance and templates +- Creative writing generators +- Technical documentation helpers + +### Analysis and review prompts +- Code review templates +- Data analysis frameworks +- Research and evaluation guides + +### Communication prompts +- Email and message templates +- Meeting and presentation outlines +- Interview and conversation starters + +### Learning and training prompts +- Educational content generators +- Skill assessment frameworks +- Tutorial and guide templates + +## Next steps + +- **[Working with context](context.md)** - Access request context in prompts +- **[Server integration](servers.md)** - Combine prompts with tools and resources +- **[Client usage](writing-clients.md)** - How clients discover and use prompts +- **[Advanced patterns](structured-output.md)** - Complex prompt structures \ No newline at end of file diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 000000000..3ba87f0a5 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,140 @@ +# Quickstart + +Get started with the MCP Python SDK in minutes by building a simple server that exposes tools, resources, and prompts. + +## Prerequisites + +- Python 3.10 or later +- [uv](https://docs.astral.sh/uv/) package manager + +## Create your first MCP server + +### 1. Set up your project + +Create a new project and add the MCP SDK: + +```bash +uv init my-mcp-server +cd my-mcp-server +uv add "mcp[cli]" +``` + +### 2. Create a simple server + +Create a file called `server.py`: + +```python +""" +Simple MCP server with tools, resources, and prompts. + +Run with: uv run mcp dev server.py +""" + +from mcp.server.fastmcp import FastMCP + +# Create an MCP server +mcp = FastMCP("Demo Server") + + +# Add a tool for mathematical operations +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers together.""" + return a + b + + +@mcp.tool() +def multiply(a: int, b: int) -> int: + """Multiply two numbers together.""" + return a * b + + +# Add a dynamic resource for greetings +@mcp.resource("greeting://{name}") +def get_greeting(name: str) -> str: + """Get a personalized greeting for someone.""" + return f"Hello, {name}! Welcome to our MCP server." + + +@mcp.resource("info://server") +def get_server_info() -> str: + """Get information about this server.""" + return """This is a demo MCP server that provides: + - Mathematical operations (add, multiply) + - Personalized greetings + - Server information + """ + + +# Add a prompt template +@mcp.prompt() +def greet_user(name: str, style: str = "friendly") -> str: + """Generate a greeting prompt for a user.""" + styles = { + "friendly": "Please write a warm, friendly greeting", + "formal": "Please write a formal, professional greeting", + "casual": "Please write a casual, relaxed greeting", + } + + style_instruction = styles.get(style, styles["friendly"]) + return f"{style_instruction} for someone named {name}." + + +if __name__ == "__main__": + # Run the server + mcp.run() +``` + +### 3. Test your server + +Test the server using the MCP Inspector: + +```bash +uv run mcp dev server.py +``` + +After installing any required dependencies, your default web browser should open the MCP Inspector where you can: + +- Call tools (`add` and `multiply`) +- Read resources (`greeting://YourName` and `info://server`) +- Use prompts (`greet_user`) + +### 4. Install in Claude Desktop + +Once you're happy with your server, install it in Claude Desktop: + +```bash +uv run mcp install server.py +``` + +Claude Desktop will now have access to your tools and resources! + +## What you've built + +Your server now provides: + +### Tools +- **add(a, b)** - Adds two numbers +- **multiply(a, b)** - Multiplies two numbers + +### Resources +- **greeting://{name}** - Personalized greetings (e.g., `greeting://Alice`) +- **info://server** - Server information + +### Prompts +- **greet_user** - Generates greeting prompts with different styles + +## Try these examples + +In the MCP Inspector or Claude Desktop, try: + +- Call the `add` tool: `{"a": 5, "b": 3}` → Returns `8` +- Read a greeting: `greeting://World` → Returns `"Hello, World! Welcome to our MCP server."` +- Use the greet_user prompt with `name: "Alice", style: "formal"` + +## Next steps + +- **[Learn about servers](servers.md)** - Understanding server lifecycle and configuration +- **[Explore tools](tools.md)** - Advanced tool patterns and structured output +- **[Working with resources](resources.md)** - Resource templates and patterns +- **[Running servers](running-servers.md)** - Development and production deployment options \ No newline at end of file diff --git a/docs/resources.md b/docs/resources.md new file mode 100644 index 000000000..923a6c2b8 --- /dev/null +++ b/docs/resources.md @@ -0,0 +1,487 @@ +# Resources + +Resources are how you expose data to LLMs through your MCP server. Think of them as GET endpoints that provide information without side effects. + +## What are resources? + +Resources provide data that LLMs can read to understand context. They should: + +- **Be read-only** - No side effects or state changes +- **Return data** - Text, JSON, or other content formats +- **Be fast** - Avoid expensive computations +- **Be cacheable** - Return consistent data for the same URI + +## Basic resource creation + +### Static resources + +```python +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("Resource Example") + +@mcp.resource("config://settings") +def get_settings() -> str: + """Get application configuration.""" + return """ + { + "theme": "dark", + "language": "en", + "debug": false, + "timeout": 30 + } + """ + +@mcp.resource("info://version") +def get_version() -> str: + """Get application version information.""" + return "MyApp v1.2.3" +``` + +### Dynamic resources with parameters + +Use URI templates to create parameterized resources: + +```python +@mcp.resource("user://{user_id}") +def get_user(user_id: str) -> str: + """Get user information by ID.""" + # In a real application, you'd fetch from a database + users = { + "1": {"name": "Alice", "email": "alice@example.com", "role": "admin"}, + "2": {"name": "Bob", "email": "bob@example.com", "role": "user"}, + } + + user = users.get(user_id) + if not user: + raise ValueError(f"User {user_id} not found") + + return f""" + User ID: {user_id} + Name: {user['name']} + Email: {user['email']} + Role: {user['role']} + """ + +@mcp.resource("file://documents/{path}") +def read_document(path: str) -> str: + """Read a document by path.""" + # Security: validate path to prevent directory traversal + if ".." in path or path.startswith("/"): + raise ValueError("Invalid path") + + # In reality, you'd read from filesystem + documents = { + "readme.md": "# My Application\\n\\nWelcome to my app!", + "api.md": "# API Documentation\\n\\nEndpoints: ...", + } + + content = documents.get(path) + if not content: + raise ValueError(f"Document {path} not found") + + return content +``` + +## Advanced resource patterns + +### Database-backed resources + +```python +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +# Mock database class +class Database: + @classmethod + async def connect(cls) -> "Database": + return cls() + + async def disconnect(self) -> None: + pass + + async def get_product(self, product_id: str) -> dict | None: + # Simulate database query + products = { + "1": {"name": "Laptop", "price": 999.99, "stock": 10}, + "2": {"name": "Mouse", "price": 29.99, "stock": 50}, + } + return products.get(product_id) + + async def search_products(self, query: str) -> list[dict]: + # Simulate search + return [ + {"id": "1", "name": "Laptop", "price": 999.99}, + {"id": "2", "name": "Mouse", "price": 29.99}, + ] + +@dataclass +class AppContext: + db: Database + +@asynccontextmanager +async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: + db = await Database.connect() + try: + yield AppContext(db=db) + finally: + await db.disconnect() + +mcp = FastMCP("Product Server", lifespan=app_lifespan) + +@mcp.resource("product://{product_id}") +async def get_product(product_id: str, ctx: Context) -> str: + """Get detailed product information.""" + db = ctx.request_context.lifespan_context.db + + product = await db.get_product(product_id) + if not product: + raise ValueError(f"Product {product_id} not found") + + return f""" + Product: {product['name']} + Price: ${product['price']:.2f} + Stock: {product['stock']} units + """ + +@mcp.resource("products://search/{query}") +async def search_products(query: str, ctx: Context) -> str: + """Search for products.""" + db = ctx.request_context.lifespan_context.db + + products = await db.search_products(query) + + if not products: + return f"No products found for '{query}'" + + result = f"Search results for '{query}':\\n\\n" + for product in products: + result += f"- {product['name']} (${product['price']:.2f})\\n" + + return result +``` + +### File system resources + +```python +import os +from pathlib import Path + +@mcp.resource("files://{path}") +def read_file(path: str) -> str: + """Read a file from the allowed directory.""" + # Security: restrict to specific directory + base_dir = Path("/allowed/directory") + file_path = base_dir / path + + # Ensure path is within allowed directory + try: + file_path = file_path.resolve() + base_dir = base_dir.resolve() + if not str(file_path).startswith(str(base_dir)): + raise ValueError("Access denied: path outside allowed directory") + except OSError: + raise ValueError("Invalid file path") + + # Read file + try: + return file_path.read_text(encoding="utf-8") + except FileNotFoundError: + raise ValueError(f"File not found: {path}") + except PermissionError: + raise ValueError(f"Permission denied: {path}") + +@mcp.resource("directory://{path}") +def list_directory(path: str) -> str: + """List files in a directory.""" + base_dir = Path("/allowed/directory") + dir_path = base_dir / path + + # Security check (same as above) + try: + dir_path = dir_path.resolve() + base_dir = base_dir.resolve() + if not str(dir_path).startswith(str(base_dir)): + raise ValueError("Access denied") + except OSError: + raise ValueError("Invalid directory path") + + try: + entries = sorted(dir_path.iterdir()) + result = f"Contents of {path}:\\n\\n" + + for entry in entries: + if entry.is_dir(): + result += f"📁 {entry.name}/\\n" + else: + size = entry.stat().st_size + result += f"📄 {entry.name} ({size} bytes)\\n" + + return result + + except FileNotFoundError: + raise ValueError(f"Directory not found: {path}") + except PermissionError: + raise ValueError(f"Permission denied: {path}") +``` + +### API-backed resources + +```python +import aiohttp +import json + +@mcp.resource("weather://{city}") +async def get_weather(city: str) -> str: + """Get weather information for a city.""" + # In a real app, use a proper weather API + api_key = os.getenv("WEATHER_API_KEY") + if not api_key: + raise ValueError("Weather API key not configured") + + url = f"https://api.openweathermap.org/data/2.5/weather" + params = { + "q": city, + "appid": api_key, + "units": "metric" + } + + async with aiohttp.ClientSession() as session: + async with session.get(url, params=params) as response: + if response.status == 404: + raise ValueError(f"City '{city}' not found") + elif response.status != 200: + raise ValueError(f"Weather API error: {response.status}") + + data = await response.json() + + weather = data["weather"][0] + main = data["main"] + + return f""" + Weather in {city}: + Condition: {weather["description"].title()} + Temperature: {main["temp"]:.1f}°C + Feels like: {main["feels_like"]:.1f}°C + Humidity: {main["humidity"]}% + """ + +@mcp.resource("news://{category}") +async def get_news(category: str) -> str: + """Get news headlines for a category.""" + # Mock news API + news_data = { + "tech": [ + "New AI breakthrough announced", + "Major software update released", + "Tech company goes public" + ], + "sports": [ + "Championship game tonight", + "New record set in marathon", + "Team trades star player" + ] + } + + headlines = news_data.get(category.lower()) + if not headlines: + raise ValueError(f"Category '{category}' not found") + + result = f"Latest {category} news:\\n\\n" + for i, headline in enumerate(headlines, 1): + result += f"{i}. {headline}\\n" + + return result +``` + +## Resource patterns and best practices + +### Structured data resources + +Return JSON for complex data structures: + +```python +import json + +@mcp.resource("api://users/{user_id}/profile") +def get_user_profile(user_id: str) -> str: + """Get structured user profile data.""" + # Simulate database lookup + profile = { + "user_id": user_id, + "profile": { + "name": "Alice Johnson", + "email": "alice@example.com", + "preferences": { + "theme": "dark", + "language": "en", + "notifications": True + }, + "stats": { + "posts_count": 42, + "followers": 156, + "following": 89 + } + } + } + + return json.dumps(profile, indent=2) +``` + +### Error handling + +Provide clear error messages: + +```python +@mcp.resource("data://{dataset}/{record_id}") +def get_record(dataset: str, record_id: str) -> str: + """Get a record from a dataset.""" + # Validate dataset + allowed_datasets = ["users", "products", "orders"] + if dataset not in allowed_datasets: + raise ValueError(f"Dataset '{dataset}' not found. Available: {', '.join(allowed_datasets)}") + + # Validate record ID format + if not record_id.isdigit(): + raise ValueError("Record ID must be a number") + + # Simulate record lookup + if int(record_id) > 1000: + raise ValueError(f"Record {record_id} not found in dataset '{dataset}'") + + return f"Record {record_id} from {dataset} dataset" +``` + +### Resource templates + +Use resource templates to help clients discover available resources: + +```python +# The @mcp.resource decorator automatically creates resource templates +# For "user://{user_id}", MCP creates a template that clients can discover + +# You can also list available values programmatically +@mcp.resource("datasets://list") +def list_datasets() -> str: + """List all available datasets.""" + datasets = ["users", "products", "orders", "analytics"] + return "Available datasets:\\n" + "\\n".join(f"- {ds}" for ds in datasets) + +@mcp.resource("users://list") +def list_users() -> str: + """List all user IDs.""" + # In reality, this would query your database + user_ids = ["1", "2", "3", "42", "100"] + return "Available user IDs:\\n" + "\\n".join(f"- {uid}" for uid in user_ids) +``` + +## Security considerations + +### Input validation + +Always validate resource parameters: + +```python +import re + +@mcp.resource("secure://data/{identifier}") +def get_secure_data(identifier: str) -> str: + """Get data with security validation.""" + # Validate identifier format + if not re.match(r"^[a-zA-Z0-9_-]+$", identifier): + raise ValueError("Invalid identifier format") + + # Check length limits + if len(identifier) > 50: + raise ValueError("Identifier too long") + + # Additional security checks... + return f"Secure data for {identifier}" +``` + +### Access control + +```python +@mcp.resource("private://{resource_id}") +async def get_private_resource(resource_id: str, ctx: Context) -> str: + """Get private resource with access control.""" + # Check if user is authenticated (in a real app) + # This would typically come from JWT token or session + user_role = getattr(ctx.session, "user_role", None) + + if user_role != "admin": + raise ValueError("Access denied: admin role required") + + return f"Private resource {resource_id} - only for admins" +``` + +## Testing resources + +### Unit testing + +```python +import pytest +from mcp.server.fastmcp import FastMCP + +def test_static_resource(): + mcp = FastMCP("Test") + + @mcp.resource("test://data") + def get_data() -> str: + return "test data" + + result = get_data() + assert result == "test data" + +def test_dynamic_resource(): + mcp = FastMCP("Test") + + @mcp.resource("test://user/{user_id}") + def get_user(user_id: str) -> str: + return f"User {user_id}" + + result = get_user("123") + assert result == "User 123" + +def test_resource_error_handling(): + mcp = FastMCP("Test") + + @mcp.resource("test://item/{item_id}") + def get_item(item_id: str) -> str: + if item_id == "404": + raise ValueError("Item not found") + return f"Item {item_id}" + + with pytest.raises(ValueError, match="Item not found"): + get_item("404") +``` + +## Common use cases + +### Configuration resources +- Application settings +- Environment variables +- Feature flags + +### Data resources +- User profiles +- Product catalogs +- Content management + +### Status resources +- System health +- Application metrics +- Service status + +### Documentation resources +- API documentation +- Help content +- Schema definitions + +## Next steps + +- **[Learn about tools](tools.md)** - Create interactive functions +- **[Working with context](context.md)** - Access request information +- **[Server patterns](servers.md)** - Advanced server configurations +- **[Client integration](writing-clients.md)** - How clients consume resources \ No newline at end of file diff --git a/docs/running-servers.md b/docs/running-servers.md new file mode 100644 index 000000000..eb688653f --- /dev/null +++ b/docs/running-servers.md @@ -0,0 +1,666 @@ +# Running servers + +Learn the different ways to run your MCP servers: development mode with the MCP Inspector, integration with Claude Desktop, direct execution, and production deployment. + +## Development mode + +### MCP Inspector + +The fastest way to test and debug your server is with the built-in MCP Inspector: + +```bash +# Basic usage +uv run mcp dev server.py + +# With additional dependencies +uv run mcp dev server.py --with pandas --with numpy + +# Mount local code as editable +uv run mcp dev server.py --with-editable . + +# Custom port +uv run mcp dev server.py --port 8001 +``` + +The MCP Inspector provides: + +- **Interactive web interface** - Test tools, resources, and prompts +- **Real-time logging** - See all server logs and debug information +- **Request/response inspection** - Debug MCP protocol messages +- **Auto-reload** - Automatically restart when code changes +- **Dependency management** - Install packages on-the-fly + +### Development server example + +```python +\"\"\" +Development server with comprehensive features. + +Run with: uv run mcp dev development_server.py +\"\"\" + +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + +# Create server with debug settings +mcp = FastMCP( + \"Development Server\", + debug=True, + log_level=\"DEBUG\" +) + +@mcp.tool() +async def debug_info(ctx: Context[ServerSession, None]) -> dict: + \"\"\"Get debug information about the server and request.\"\"\" + await ctx.debug(\"Debug info requested\") + + return { + \"server\": { + \"name\": ctx.fastmcp.name, + \"debug_mode\": ctx.fastmcp.settings.debug, + \"log_level\": ctx.fastmcp.settings.log_level + }, + \"request\": { + \"request_id\": ctx.request_id, + \"client_id\": ctx.client_id + } + } + +@mcp.resource(\"dev://logs/{level}\") +def get_logs(level: str) -> str: + \"\"\"Get simulated log entries for development.\"\"\" + logs = { + \"info\": \"2024-01-01 10:00:00 INFO: Server started\\n2024-01-01 10:01:00 INFO: Client connected\", + \"debug\": \"2024-01-01 10:00:00 DEBUG: Initializing server\\n2024-01-01 10:00:01 DEBUG: Loading configuration\", + \"error\": \"2024-01-01 10:02:00 ERROR: Failed to process request\\n2024-01-01 10:02:01 ERROR: Database connection lost\" + } + return logs.get(level, \"No logs found for level: \" + level) + +if __name__ == \"__main__\": + # Run with development settings + mcp.run() +``` + +## Claude Desktop integration + +### Installing servers + +Install your server in Claude Desktop for production use: + +```bash +# Basic installation +uv run mcp install server.py + +# Custom server name +uv run mcp install server.py --name \"My Analytics Server\" + +# With environment variables +uv run mcp install server.py -v API_KEY=abc123 -v DB_URL=postgres://localhost/myapp + +# From environment file +uv run mcp install server.py -f .env + +# Specify custom port +uv run mcp install server.py --port 8080 +``` + +### Example production server + +```python +\"\"\" +Production-ready MCP server for Claude Desktop. + +Install with: uv run mcp install production_server.py --name \"Analytics Server\" +\"\"\" + +import os +from mcp.server.fastmcp import FastMCP + +# Create production server +mcp = FastMCP( + \"Analytics Server\", + instructions=\"Provides data analytics and business intelligence tools\", + debug=False, # Disable debug mode for production + log_level=\"INFO\" +) + +@mcp.tool() +def calculate_metrics(data: list[float]) -> dict[str, float]: + \"\"\"Calculate key metrics from numerical data.\"\"\" + if not data: + raise ValueError(\"Data cannot be empty\") + + return { + \"count\": len(data), + \"mean\": sum(data) / len(data), + \"min\": min(data), + \"max\": max(data), + \"sum\": sum(data) + } + +@mcp.resource(\"config://database\") +def get_database_config() -> str: + \"\"\"Get database configuration from environment.\"\"\" + db_url = os.getenv(\"DB_URL\", \"sqlite:///default.db\") + return f\"Database URL: {db_url}\" + +@mcp.prompt() +def analyze_data(dataset_name: str, analysis_type: str = \"summary\") -> str: + \"\"\"Generate data analysis prompt.\"\"\" + return f\"\"\"Please analyze the {dataset_name} dataset. + +Analysis type: {analysis_type} + +Provide: +1. Key insights and trends +2. Notable patterns or anomalies +3. Actionable recommendations +4. Data quality assessment +\"\"\" + +if __name__ == \"__main__\": + mcp.run() +``` + +### Environment configuration + +Create a `.env` file for environment variables: + +```bash +# .env file for MCP server +DB_URL=postgresql://user:pass@localhost/analytics +API_KEY=your-secret-api-key +REDIS_URL=redis://localhost:6379/0 +LOG_LEVEL=INFO +DEBUG_MODE=false +``` + +## Direct execution + +### Simple execution + +Run servers directly for custom deployments: + +```python +\"\"\" +Direct execution example. + +Run with: python direct_server.py +\"\"\" + +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP(\"Direct Server\") + +@mcp.tool() +def hello(name: str = \"World\") -> str: + \"\"\"Say hello to someone.\"\"\" + return f\"Hello, {name}!\" + +def main(): + \"\"\"Entry point for direct execution.\"\"\" + # Run with default transport (stdio) + mcp.run() + +if __name__ == \"__main__\": + main() +``` + +### Command-line arguments + +Add CLI support for flexible execution: + +```python +\"\"\" +Server with command-line interface. + +Run with: python cli_server.py --port 8080 --debug +\"\"\" + +import argparse +from mcp.server.fastmcp import FastMCP + +def create_server(debug: bool = False, log_level: str = \"INFO\") -> FastMCP: + \"\"\"Create server with configuration.\"\"\" + return FastMCP( + \"CLI Server\", + debug=debug, + log_level=log_level + ) + +def main(): + \"\"\"Main entry point with argument parsing.\"\"\" + parser = argparse.ArgumentParser(description=\"MCP Server with CLI\") + parser.add_argument(\"--port\", type=int, default=8000, help=\"Server port\") + parser.add_argument(\"--host\", default=\"localhost\", help=\"Server host\") + parser.add_argument(\"--debug\", action=\"store_true\", help=\"Enable debug mode\") + parser.add_argument(\"--log-level\", choices=[\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\"], + default=\"INFO\", help=\"Log level\") + parser.add_argument(\"--transport\", choices=[\"stdio\", \"sse\", \"streamable-http\"], + default=\"stdio\", help=\"Transport type\") + + args = parser.parse_args() + + # Create server with parsed arguments + mcp = create_server(debug=args.debug, log_level=args.log_level) + + @mcp.tool() + def get_server_config() -> dict: + \"\"\"Get current server configuration.\"\"\" + return { + \"host\": args.host, + \"port\": args.port, + \"debug\": args.debug, + \"log_level\": args.log_level, + \"transport\": args.transport + } + + # Run with specified configuration + mcp.run( + transport=args.transport, + host=args.host, + port=args.port + ) + +if __name__ == \"__main__\": + main() +``` + +## Transport options + +### stdio transport (default) + +Best for Claude Desktop integration and command-line tools: + +```python +# Run with stdio (default) +mcp.run() # or mcp.run(transport=\"stdio\") +``` + +### HTTP transports + +#### SSE (Server-Sent Events) + +```python +# Run with SSE transport +mcp.run(transport=\"sse\", host=\"0.0.0.0\", port=8000) +``` + +#### Streamable HTTP (recommended for production) + +```python +# Run with Streamable HTTP transport +mcp.run(transport=\"streamable-http\", host=\"0.0.0.0\", port=8000) + +# With stateless configuration (better for scaling) +mcp = FastMCP(\"Stateless Server\", stateless_http=True) +mcp.run(transport=\"streamable-http\") +``` + +### Transport comparison + +| Transport | Best for | Pros | Cons | +|-----------|----------|------|------| +| **stdio** | Claude Desktop, CLI tools | Simple, reliable | Not web-accessible | +| **SSE** | Web integration, streaming | Real-time updates | Being superseded | +| **Streamable HTTP** | Production, scaling | Stateful/stateless, resumable | More complex | + +## Production deployment + +### Docker deployment + +Create a `Dockerfile`: + +```dockerfile +FROM python:3.11-slim + +WORKDIR /app + +# Install uv +RUN pip install uv + +# Copy project files +COPY pyproject.toml uv.lock ./ +COPY src/ src/ + +# Install dependencies +RUN uv sync --frozen + +# Copy server code +COPY server.py . + +# Expose port +EXPOSE 8000 + +# Run server +CMD [\"uv\", \"run\", \"python\", \"server.py\", \"--transport\", \"streamable-http\", \"--host\", \"0.0.0.0\", \"--port\", \"8000\"] +``` + +Build and run: + +```bash +# Build image +docker build -t my-mcp-server . + +# Run container +docker run -p 8000:8000 -e API_KEY=secret my-mcp-server +``` + +### Docker Compose + +Create `docker-compose.yml`: + +```yaml +version: '3.8' + +services: + mcp-server: + build: . + ports: + - \"8000:8000\" + environment: + - API_KEY=${API_KEY} + - DB_URL=postgresql://postgres:password@db:5432/myapp + - REDIS_URL=redis://redis:6379/0 + depends_on: + - db + - redis + restart: unless-stopped + + db: + image: postgres:15 + environment: + POSTGRES_DB: myapp + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + volumes: + - postgres_data:/var/lib/postgresql/data + restart: unless-stopped + + redis: + image: redis:7-alpine + restart: unless-stopped + +volumes: + postgres_data: +``` + +### Process management with systemd + +Create `/etc/systemd/system/mcp-server.service`: + +```ini +[Unit] +Description=MCP Server +After=network.target + +[Service] +Type=simple +User=mcp +WorkingDirectory=/opt/mcp-server +ExecStart=/opt/mcp-server/.venv/bin/python server.py --transport streamable-http --host 0.0.0.0 --port 8000 +Restart=always +RestartSec=5 +Environment=PATH=/opt/mcp-server/.venv/bin +EnvironmentFile=/opt/mcp-server/.env + +[Install] +WantedBy=multi-user.target +``` + +Enable and start: + +```bash +sudo systemctl enable mcp-server +sudo systemctl start mcp-server +sudo systemctl status mcp-server +``` + +### Reverse proxy with nginx + +Create `/etc/nginx/sites-available/mcp-server`: + +```nginx +server { + listen 80; + server_name your-domain.com; + + location / { + proxy_pass http://localhost:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection \"upgrade\"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # For Server-Sent Events + proxy_buffering off; + proxy_cache off; + } +} +``` + +## Monitoring and health checks + +### Health check endpoint + +```python +@mcp.tool() +async def health_check() -> dict: + \"\"\"Server health check endpoint.\"\"\" + import time + import psutil + + return { + \"status\": \"healthy\", + \"timestamp\": time.time(), + \"uptime\": time.time() - server_start_time, + \"memory_usage\": psutil.Process().memory_info().rss / 1024 / 1024, # MB + \"cpu_percent\": psutil.Process().cpu_percent() + } +``` + +### Logging configuration + +```python +import logging +import sys + +def setup_logging(log_level: str = \"INFO\", log_file: str | None = None): + \"\"\"Configure logging for production.\"\"\" + handlers = [logging.StreamHandler(sys.stdout)] + + if log_file: + handlers.append(logging.FileHandler(log_file)) + + logging.basicConfig( + level=getattr(logging, log_level), + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=handlers + ) + +# Use in production server +if __name__ == \"__main__\": + setup_logging(log_level=\"INFO\", log_file=\"/var/log/mcp-server.log\") + mcp.run() +``` + +### Process monitoring + +Monitor your server with tools like: + +- **Supervisor** - Process management and auto-restart +- **PM2** - Node.js process manager (works with Python too) +- **systemd** - System service management +- **Docker health checks** - Container health monitoring + +Example supervisor config: + +```ini +[program:mcp-server] +command=/opt/mcp-server/.venv/bin/python server.py +directory=/opt/mcp-server +user=mcp +autostart=true +autorestart=true +redirect_stderr=true +stdout_logfile=/var/log/mcp-server.log +environment=PATH=/opt/mcp-server/.venv/bin +``` + +## Performance optimization + +### Server configuration + +```python +# Optimized production server +mcp = FastMCP( + \"Production Server\", + debug=False, # Disable debug mode + log_level=\"INFO\", # Reduce log verbosity + stateless_http=True, # Enable stateless mode for scaling + host=\"0.0.0.0\", # Accept connections from any host + port=8000 +) +``` + +### Resource management + +```python +from contextlib import asynccontextmanager +from collections.abc import AsyncIterator +import aioredis +import asyncpg + +@dataclass +class AppContext: + db_pool: asyncpg.Pool + redis: aioredis.Redis + +@asynccontextmanager +async def optimized_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: + \"\"\"Optimized lifespan with connection pooling.\"\"\" + # Create connection pools + db_pool = await asyncpg.create_pool( + \"postgresql://user:pass@localhost/db\", + min_size=5, + max_size=20 + ) + + redis = aioredis.from_url( + \"redis://localhost:6379\", + encoding=\"utf-8\", + decode_responses=True + ) + + try: + yield AppContext(db_pool=db_pool, redis=redis) + finally: + await db_pool.close() + await redis.close() + +mcp = FastMCP(\"Optimized Server\", lifespan=optimized_lifespan) +``` + +## Troubleshooting + +### Common issues + +**Server not starting:** +```bash +# Check if port is in use +lsof -i :8000 + +# Check server logs +uv run mcp dev server.py --log-level DEBUG +``` + +**Claude Desktop not connecting:** +```bash +# Verify installation +uv run mcp list + +# Test server manually +uv run mcp dev server.py + +# Check Claude Desktop logs (macOS) +tail -f ~/Library/Logs/Claude/mcp-server.log +``` + +**Performance issues:** +```bash +# Monitor resource usage +htop + +# Check connection limits +ulimit -n + +# Profile Python code +python -m cProfile server.py +``` + +### Debug tools + +```python +@mcp.tool() +async def debug_server(ctx: Context) -> dict: + \"\"\"Get comprehensive debug information.\"\"\" + import platform + import sys + import os + + return { + \"python\": { + \"version\": sys.version, + \"executable\": sys.executable, + \"platform\": platform.platform() + }, + \"environment\": { + \"cwd\": os.getcwd(), + \"env_vars\": dict(os.environ) + }, + \"server\": { + \"name\": ctx.fastmcp.name, + \"settings\": ctx.fastmcp.settings.__dict__ + }, + \"request\": { + \"id\": ctx.request_id, + \"client_id\": ctx.client_id + } + } +``` + +## Best practices + +### Development workflow + +1. **Start with MCP Inspector** - Use `mcp dev` for rapid iteration +2. **Test with Claude Desktop** - Install and test real-world usage +3. **Add environment configuration** - Use `.env` files for settings +4. **Implement health checks** - Add monitoring and debugging tools +5. **Plan deployment** - Choose appropriate transport and hosting + +### Production readiness + +- **Error handling** - Comprehensive error handling and recovery +- **Logging** - Structured logging with appropriate levels +- **Security** - Authentication, input validation, and rate limiting +- **Monitoring** - Health checks, metrics, and alerting +- **Scaling** - Connection pooling, stateless design, and load balancing + +### Security considerations + +- **Input validation** - Validate all tool and resource parameters +- **Environment variables** - Store secrets in environment, not code +- **Network security** - Use HTTPS in production, restrict access +- **Rate limiting** - Prevent abuse and resource exhaustion +- **Authentication** - Implement proper authentication for sensitive operations + +## Next steps + +- **[Streamable HTTP](streamable-http.md)** - Modern HTTP transport details +- **[ASGI integration](asgi-integration.md)** - Integrate with web frameworks +- **[Authentication](authentication.md)** - Secure your production servers +- **[Client development](writing-clients.md)** - Build clients to connect to your servers \ No newline at end of file diff --git a/docs/sampling.md b/docs/sampling.md new file mode 100644 index 000000000..92d7bb1a3 --- /dev/null +++ b/docs/sampling.md @@ -0,0 +1,628 @@ +# Sampling + +Sampling allows MCP servers to interact with LLMs by requesting text generation. This enables servers to leverage LLM capabilities within their tools and workflows. + +## What is sampling? + +Sampling enables servers to: + +- **Generate text** - Request LLM text completion +- **Interactive workflows** - Create multi-step conversations +- **Content creation** - Generate dynamic content based on data +- **Decision making** - Use LLM reasoning in server logic + +## Basic sampling + +### Simple text generation + +```python +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession +from mcp.types import SamplingMessage, TextContent + +mcp = FastMCP("Sampling Example") + +@mcp.tool() +async def generate_summary(text: str, ctx: Context[ServerSession, None]) -> str: + """Generate a summary using LLM sampling.""" + prompt = f"Please provide a concise summary of the following text:\\n\\n{text}" + + result = await ctx.session.create_message( + messages=[ + SamplingMessage( + role="user", + content=TextContent(type="text", text=prompt) + ) + ], + max_tokens=150 + ) + + if result.content.type == "text": + return result.content.text + return str(result.content) + +@mcp.tool() +async def creative_writing(topic: str, style: str, ctx: Context) -> str: + """Generate creative content with specific style.""" + prompt = f"Write a short {style} piece about {topic}. Be creative and engaging." + + result = await ctx.session.create_message( + messages=[ + SamplingMessage( + role="user", + content=TextContent(type="text", text=prompt) + ) + ], + max_tokens=300 + ) + + return result.content.text if result.content.type == "text" else str(result.content) +``` + +### Conversational sampling + +```python +@mcp.tool() +async def interactive_advisor( + user_question: str, + context: str, + ctx: Context[ServerSession, None] +) -> str: + """Provide interactive advice using conversation.""" + messages = [ + SamplingMessage( + role="system", + content=TextContent( + type="text", + text=f"You are a helpful advisor. Context: {context}" + ) + ), + SamplingMessage( + role="user", + content=TextContent(type="text", text=user_question) + ) + ] + + result = await ctx.session.create_message( + messages=messages, + max_tokens=200, + temperature=0.7 # Add some creativity + ) + + return result.content.text if result.content.type == "text" else "Unable to generate response" +``` + +## Advanced sampling patterns + +### Multi-turn conversations + +```python +@mcp.tool() +async def research_assistant( + topic: str, + depth: str = "overview", + ctx: Context[ServerSession, None] +) -> dict[str, str]: + """Conduct research using multi-turn conversation.""" + + # First, ask for an outline + outline_result = await ctx.session.create_message( + messages=[ + SamplingMessage( + role="user", + content=TextContent( + type="text", + text=f"Create a research outline for the topic: {topic}. " + f"Depth level: {depth}. Provide 3-5 main points." + ) + ) + ], + max_tokens=200 + ) + + outline = outline_result.content.text if outline_result.content.type == "text" else "" + + # Then expand on each point + expansion_result = await ctx.session.create_message( + messages=[ + SamplingMessage(role="user", content=TextContent(type="text", text=f"Based on this outline:\\n{outline}\\n\\nProvide detailed explanations for each main point about {topic}.")), + ], + max_tokens=500 + ) + + expansion = expansion_result.content.text if expansion_result.content.type == "text" else "" + + return { + "topic": topic, + "outline": outline, + "detailed_analysis": expansion + } + +@mcp.tool() +async def brainstorm_solutions( + problem: str, + constraints: list[str], + ctx: Context[ServerSession, None] +) -> dict: + """Brainstorm solutions through iterative sampling.""" + + # Generate initial ideas + constraints_text = "\\n- ".join(constraints) if constraints else "None specified" + + initial_ideas = await ctx.session.create_message( + messages=[ + SamplingMessage( + role="user", + content=TextContent( + type="text", + text=f"Brainstorm 5 creative solutions for this problem: {problem}\\n\\nConstraints:\\n- {constraints_text}" + ) + ) + ], + max_tokens=300 + ) + + ideas = initial_ideas.content.text if initial_ideas.content.type == "text" else "" + + # Evaluate and refine ideas + evaluation = await ctx.session.create_message( + messages=[ + SamplingMessage( + role="user", + content=TextContent( + type="text", + text=f"Evaluate these solutions for the problem '{problem}':\\n\\n{ideas}\\n\\nRank them by feasibility and effectiveness. Suggest improvements for the top 2 solutions." + ) + ) + ], + max_tokens=400 + ) + + eval_text = evaluation.content.text if evaluation.content.type == "text" else "" + + return { + "problem": problem, + "constraints": constraints, + "initial_ideas": ideas, + "evaluation_and_refinement": eval_text + } +``` + +### Data-driven sampling + +```python +@mcp.tool() +async def analyze_data_with_llm( + data: dict, + analysis_type: str, + ctx: Context[ServerSession, None] +) -> str: + """Analyze data using LLM reasoning.""" + + # Convert data to readable format + data_summary = "\\n".join([f"- {k}: {v}" for k, v in data.items()]) + + analysis_prompts = { + "trends": f"Analyze the following data for trends and patterns:\\n{data_summary}\\n\\nWhat trends do you observe? What might be causing them?", + "insights": f"Provide business insights from this data:\\n{data_summary}\\n\\nWhat insights can help improve decision making?", + "recommendations": f"Based on this data:\\n{data_summary}\\n\\nWhat are your top 3 recommendations for action?" + } + + prompt = analysis_prompts.get(analysis_type, analysis_prompts["insights"]) + + result = await ctx.session.create_message( + messages=[ + SamplingMessage( + role="user", + content=TextContent(type="text", text=prompt) + ) + ], + max_tokens=400 + ) + + return result.content.text if result.content.type == "text" else "Analysis unavailable" + +@mcp.tool() +async def generate_report( + data_points: list[dict], + report_type: str, + ctx: Context[ServerSession, None] +) -> str: + """Generate formatted reports using sampling.""" + + # Prepare data summary + summary_lines = [] + for i, point in enumerate(data_points, 1): + summary_lines.append(f"{i}. {point}") + + data_text = "\\n".join(summary_lines) + + report_prompts = { + "executive": f"Create an executive summary report from this data:\\n{data_text}\\n\\nFormat: Title, Key Findings (3-4 bullet points), Recommendations", + "detailed": f"Create a detailed analysis report from this data:\\n{data_text}\\n\\nInclude: Introduction, Methodology, Findings, Analysis, Conclusions", + "technical": f"Create a technical report from this data:\\n{data_text}\\n\\nFocus on: Data Quality, Statistical Analysis, Technical Findings, Implementation Notes" + } + + prompt = report_prompts.get(report_type, report_prompts["executive"]) + + result = await ctx.session.create_message( + messages=[ + SamplingMessage( + role="user", + content=TextContent(type="text", text=prompt) + ) + ], + max_tokens=600 + ) + + return result.content.text if result.content.type == "text" else "Report generation failed" +``` + +## Sampling with context + +### Using server data in sampling + +```python +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +@dataclass +class KnowledgeBase: + """Mock knowledge base.""" + + def get_context(self, topic: str) -> str: + knowledge = { + "python": "Python is a high-level programming language known for readability and versatility.", + "ai": "Artificial Intelligence involves creating systems that can perform tasks requiring human intelligence.", + "web": "Web development involves creating websites and web applications using various technologies." + } + return knowledge.get(topic.lower(), "No specific knowledge available for this topic.") + +@dataclass +class AppContext: + knowledge_base: KnowledgeBase + +@asynccontextmanager +async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: + kb = KnowledgeBase() + yield AppContext(knowledge_base=kb) + +mcp = FastMCP("Knowledge Assistant", lifespan=app_lifespan) + +@mcp.tool() +async def expert_advice( + question: str, + topic: str, + ctx: Context[ServerSession, AppContext] +) -> str: + """Provide expert advice using knowledge base context.""" + + # Get relevant context from knowledge base + kb = ctx.request_context.lifespan_context.knowledge_base + context_info = kb.get_context(topic) + + # Create enhanced prompt with context + prompt = f"""Context: {context_info} + +Question: {question} + +Please provide expert advice based on the context provided above. If the context doesn't fully cover the question, acknowledge the limitations and provide what guidance you can.""" + + result = await ctx.session.create_message( + messages=[ + SamplingMessage( + role="user", + content=TextContent(type="text", text=prompt) + ) + ], + max_tokens=300 + ) + + advice = result.content.text if result.content.type == "text" else "Unable to provide advice" + + await ctx.info(f"Expert advice provided for topic: {topic}") + + return advice +``` + +### Resource-informed sampling + +```python +@mcp.resource("knowledge://{domain}") +def get_knowledge(domain: str) -> str: + """Get knowledge about a domain.""" + knowledge_db = { + "marketing": "Marketing involves promoting products/services through various channels...", + "finance": "Finance deals with money management, investments, and financial planning...", + "technology": "Technology encompasses computing, software, hardware, and digital systems..." + } + return knowledge_db.get(domain, "No knowledge available for this domain") + +@mcp.tool() +async def contextual_answer( + question: str, + domain: str, + ctx: Context[ServerSession, None] +) -> str: + """Answer questions using domain knowledge from resources.""" + + try: + # Read domain knowledge from resource + knowledge_resource = await ctx.read_resource(f"knowledge://{domain}") + + if knowledge_resource.contents: + content = knowledge_resource.contents[0] + domain_knowledge = content.text if hasattr(content, 'text') else "" + + prompt = f"""Domain Knowledge: {domain_knowledge} + +Question: {question} + +Please answer the question using the domain knowledge provided above. Be specific and reference the knowledge when relevant.""" + + result = await ctx.session.create_message( + messages=[ + SamplingMessage( + role="user", + content=TextContent(type="text", text=prompt) + ) + ], + max_tokens=250 + ) + + return result.content.text if result.content.type == "text" else "Unable to generate answer" + + except Exception as e: + await ctx.error(f"Failed to read domain knowledge: {e}") + + # Fallback to general answer + result = await ctx.session.create_message( + messages=[ + SamplingMessage( + role="user", + content=TextContent(type="text", text=question) + ) + ], + max_tokens=200 + ) + + return result.content.text if result.content.type == "text" else "Unable to provide answer" +``` + +## Error handling and best practices + +### Robust sampling implementation + +```python +@mcp.tool() +async def robust_generation( + prompt: str, + ctx: Context[ServerSession, None], + max_retries: int = 3 +) -> dict[str, any]: + """Generate text with error handling and retries.""" + + for attempt in range(max_retries): + try: + await ctx.debug(f"Generation attempt {attempt + 1}/{max_retries}") + + result = await ctx.session.create_message( + messages=[ + SamplingMessage( + role="user", + content=TextContent(type="text", text=prompt) + ) + ], + max_tokens=200 + ) + + if result.content.type == "text" and result.content.text.strip(): + await ctx.info("Text generation successful") + return { + "success": True, + "content": result.content.text, + "attempts": attempt + 1 + } + else: + await ctx.warning(f"Empty response on attempt {attempt + 1}") + + except Exception as e: + await ctx.warning(f"Generation failed on attempt {attempt + 1}: {e}") + if attempt == max_retries - 1: # Last attempt + await ctx.error("All generation attempts failed") + return { + "success": False, + "error": str(e), + "attempts": max_retries + } + + return { + "success": False, + "error": "Maximum retries exceeded", + "attempts": max_retries + } + +@mcp.tool() +async def safe_sampling( + user_input: str, + ctx: Context[ServerSession, None] +) -> str: + """Safe sampling with input validation and output filtering.""" + + # Input validation + if len(user_input) > 1000: + raise ValueError("Input too long (max 1000 characters)") + + if not user_input.strip(): + raise ValueError("Empty input not allowed") + + # Content filtering for prompt injection + suspicious_patterns = ["ignore previous", "system:", "assistant:", "role:"] + user_input_lower = user_input.lower() + + for pattern in suspicious_patterns: + if pattern in user_input_lower: + await ctx.warning(f"Suspicious pattern detected: {pattern}") + raise ValueError("Input contains potentially harmful content") + + try: + result = await ctx.session.create_message( + messages=[ + SamplingMessage( + role="user", + content=TextContent(type="text", text=f"Please respond to: {user_input}") + ) + ], + max_tokens=150 + ) + + response = result.content.text if result.content.type == "text" else "" + + # Output validation + if not response or len(response.strip()) < 10: + await ctx.warning("Generated response too short") + return "Unable to generate meaningful response" + + return response + + except Exception as e: + await ctx.error(f"Sampling failed: {e}") + return "Text generation service unavailable" +``` + +## Performance optimization + +### Caching and batching + +```python +from functools import lru_cache +import hashlib + +class SamplingCache: + """Simple cache for sampling results.""" + + def __init__(self, max_size: int = 100): + self.cache = {} + self.max_size = max_size + + def get_key(self, messages: list, max_tokens: int) -> str: + """Generate cache key from messages and parameters.""" + content = str(messages) + str(max_tokens) + return hashlib.md5(content.encode()).hexdigest() + + def get(self, key: str) -> str | None: + return self.cache.get(key) + + def set(self, key: str, value: str): + if len(self.cache) >= self.max_size: + # Simple LRU: remove oldest entry + oldest_key = next(iter(self.cache)) + del self.cache[oldest_key] + self.cache[key] = value + +# Global cache instance +sampling_cache = SamplingCache() + +@mcp.tool() +async def cached_generation( + prompt: str, + ctx: Context[ServerSession, None] +) -> str: + """Generate text with caching for repeated prompts.""" + + messages = [SamplingMessage(role="user", content=TextContent(type="text", text=prompt))] + max_tokens = 200 + + # Check cache first + cache_key = sampling_cache.get_key(messages, max_tokens) + cached_result = sampling_cache.get(cache_key) + + if cached_result: + await ctx.debug("Returning cached result") + return cached_result + + # Generate new response + result = await ctx.session.create_message( + messages=messages, + max_tokens=max_tokens + ) + + response = result.content.text if result.content.type == "text" else "" + + # Cache the result + sampling_cache.set(cache_key, response) + await ctx.debug("Result cached for future use") + + return response +``` + +## Testing sampling functionality + +### Unit testing with mocks + +```python +import pytest +from unittest.mock import AsyncMock, Mock + +@pytest.mark.asyncio +async def test_sampling_tool(): + """Test sampling tool with mocked session.""" + + # Mock session and result + mock_session = AsyncMock() + mock_result = Mock() + mock_result.content.type = "text" + mock_result.content.text = "Generated response" + + mock_session.create_message.return_value = mock_result + + # Mock context + mock_ctx = Mock() + mock_ctx.session = mock_session + + # Test the function + @mcp.tool() + async def test_generation(prompt: str, ctx: Context) -> str: + result = await ctx.session.create_message( + messages=[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))], + max_tokens=100 + ) + return result.content.text + + result = await test_generation("test prompt", mock_ctx) + + assert result == "Generated response" + mock_session.create_message.assert_called_once() +``` + +## Best practices + +### Sampling guidelines + +- **Validate inputs** - Always sanitize user input before sampling +- **Handle errors gracefully** - Implement retries and fallbacks +- **Use appropriate max_tokens** - Balance response quality and cost +- **Cache results** - Cache expensive operations when appropriate +- **Monitor usage** - Track sampling costs and performance + +### Security considerations + +- **Prompt injection prevention** - Filter suspicious input patterns +- **Output validation** - Verify generated content is appropriate +- **Rate limiting** - Prevent abuse of expensive sampling operations +- **Content filtering** - Remove sensitive information from responses + +### Performance tips + +- **Batch operations** - Combine multiple sampling requests when possible +- **Optimize prompts** - Use clear, concise prompts for better results +- **Set reasonable limits** - Use appropriate token limits and timeouts +- **Cache intelligently** - Cache expensive computations and common queries + +## Next steps + +- **[Context usage](context.md)** - Advanced context patterns with sampling +- **[Elicitation](elicitation.md)** - Interactive user input collection +- **[Progress reporting](progress-logging.md)** - Progress updates during long sampling +- **[Authentication](authentication.md)** - Securing sampling endpoints \ No newline at end of file diff --git a/docs/servers.md b/docs/servers.md new file mode 100644 index 000000000..605fdbddb --- /dev/null +++ b/docs/servers.md @@ -0,0 +1,353 @@ +# Servers + +Learn how to create and manage MCP servers, including lifecycle management, configuration, and advanced patterns. + +## What is an MCP server? + +An MCP server exposes functionality to LLM applications through three core primitives: + +- **Resources** - Data that can be read by LLMs +- **Tools** - Functions that LLMs can call +- **Prompts** - Templates for LLM interactions + +The FastMCP framework provides a high-level, decorator-based way to build servers quickly. + +## Basic server creation + +### Minimal server + +```python +from mcp.server.fastmcp import FastMCP + +# Create a server +mcp = FastMCP("My Server") + +@mcp.tool() +def hello(name: str = "World") -> str: + """Say hello to someone.""" + return f"Hello, {name}!" + +if __name__ == "__main__": + mcp.run() +``` + +### Server with configuration + +```python +from mcp.server.fastmcp import FastMCP + +# Create server with custom configuration +mcp = FastMCP( + name="Analytics Server", + instructions="Provides data analytics and reporting tools" +) + +@mcp.tool() +def analyze_data(data: list[int]) -> dict[str, float]: + """Analyze a list of numbers.""" + return { + "mean": sum(data) / len(data), + "max": max(data), + "min": min(data), + "count": len(data) + } +``` + +## Server lifecycle management + +### Using lifespan for startup/shutdown + +For servers that need to initialize resources (databases, connections, etc.), use the lifespan pattern: + +```python +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + + +# Mock database for example +class Database: + @classmethod + async def connect(cls) -> "Database": + print("Connecting to database...") + return cls() + + async def disconnect(self) -> None: + print("Disconnecting from database...") + + def query(self, sql: str) -> dict: + return {"result": f"Query result for: {sql}"} + + +@dataclass +class AppContext: + """Application context with typed dependencies.""" + db: Database + + +@asynccontextmanager +async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: + """Manage application lifecycle with type-safe context.""" + # Startup: initialize resources + db = await Database.connect() + try: + yield AppContext(db=db) + finally: + # Shutdown: cleanup resources + await db.disconnect() + + +# Create server with lifespan +mcp = FastMCP("Database Server", lifespan=app_lifespan) + + +@mcp.tool() +def query_database(sql: str, ctx: Context[ServerSession, AppContext]) -> dict: + """Execute a database query.""" + # Access the database from lifespan context + db = ctx.request_context.lifespan_context.db + return db.query(sql) +``` + +### Benefits of lifespan management + +- **Resource initialization** - Set up databases, API clients, configuration +- **Graceful shutdown** - Clean up resources when server stops +- **Type safety** - Access initialized resources with full type hints +- **Shared state** - Resources available to all request handlers + +## Server configuration + +### Development vs production settings + +```python +from mcp.server.fastmcp import FastMCP + +# Development server with debug features +dev_mcp = FastMCP( + "Dev Server", + debug=True, + log_level="DEBUG" +) + +# Production server with optimized settings +prod_mcp = FastMCP( + "Production Server", + debug=False, + log_level="INFO", + stateless_http=True # Better for scaling +) +``` + +### Transport configuration + +```python +# Configure for different transports +mcp = FastMCP( + "Multi-Transport Server", + host="0.0.0.0", # Accept connections from any host + port=8000, + mount_path="/api/mcp", # Custom path for HTTP transport + sse_path="/events", # Custom SSE endpoint +) + +# Run with specific transport +if __name__ == "__main__": + mcp.run(transport="streamable-http") # or "stdio", "sse" +``` + +## Error handling and validation + +### Input validation + +```python +from typing import Annotated +from pydantic import Field, validator + +@mcp.tool() +def process_age( + age: Annotated[int, Field(ge=0, le=150, description="Person's age")] +) -> str: + """Process a person's age with validation.""" + if age < 18: + return "Minor" + elif age < 65: + return "Adult" + else: + return "Senior" +``` + +### Error handling patterns + +```python +@mcp.tool() +def divide_numbers(a: float, b: float) -> float: + """Divide two numbers with error handling.""" + if b == 0: + raise ValueError("Cannot divide by zero") + return a / b + +@mcp.tool() +async def fetch_data(url: str, ctx: Context) -> str: + """Fetch data with proper error handling.""" + try: + # Simulate network request + if not url.startswith("http"): + raise ValueError("URL must start with http or https") + + await ctx.info(f"Fetching data from {url}") + # ... actual implementation + return "Data fetched successfully" + + except ValueError as e: + await ctx.error(f"Invalid URL: {e}") + raise + except Exception as e: + await ctx.error(f"Failed to fetch data: {e}") + raise +``` + +## Server capabilities and metadata + +### Declaring capabilities + +```python +# Server automatically declares capabilities based on registered handlers +mcp = FastMCP("Feature Server") + +# Adding tools automatically enables the 'tools' capability +@mcp.tool() +def my_tool() -> str: + return "Tool result" + +# Adding resources automatically enables the 'resources' capability +@mcp.resource("data://{id}") +def get_data(id: str) -> str: + return f"Data for {id}" + +# Adding prompts automatically enables the 'prompts' capability +@mcp.prompt() +def my_prompt() -> str: + return "Prompt template" +``` + +### Server metadata access + +```python +@mcp.tool() +def server_info(ctx: Context) -> dict: + """Get information about the current server.""" + return { + "name": ctx.fastmcp.name, + "instructions": ctx.fastmcp.instructions, + "debug_mode": ctx.fastmcp.settings.debug, + "host": ctx.fastmcp.settings.host, + "port": ctx.fastmcp.settings.port, + } +``` + +## Testing servers + +### Unit testing individual components + +```python +import pytest +from mcp.server.fastmcp import FastMCP + +def test_server_creation(): + mcp = FastMCP("Test Server") + assert mcp.name == "Test Server" + +@pytest.mark.asyncio +async def test_tool_functionality(): + mcp = FastMCP("Test") + + @mcp.tool() + def add(a: int, b: int) -> int: + return a + b + + # Test the underlying function + result = add(2, 3) + assert result == 5 +``` + +### Integration testing with MCP Inspector + +```bash +# Start server in test mode +uv run mcp dev server.py --port 8001 + +# Test with curl +curl -X POST http://localhost:8001/mcp \ + -H "Content-Type: application/json" \ + -d '{"method": "tools/list", "params": {}}' +``` + +## Common patterns + +### Environment-based configuration + +```python +import os +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP( + "Configurable Server", + debug=os.getenv("DEBUG", "false").lower() == "true", + host=os.getenv("HOST", "localhost"), + port=int(os.getenv("PORT", "8000")) +) +``` + +### Multi-server applications + +```python +# Create specialized servers for different domains +auth_server = FastMCP("Auth Server") +data_server = FastMCP("Data Server") + +@auth_server.tool() +def login(username: str, password: str) -> str: + """Handle user authentication.""" + # ... auth logic + return "Login successful" + +@data_server.tool() +def get_user_data(user_id: str) -> dict: + """Retrieve user data.""" + # ... data retrieval logic + return {"user_id": user_id, "name": "John Doe"} +``` + +## Best practices + +### Server design + +- **Single responsibility** - Each server should have a focused purpose +- **Stateless when possible** - Avoid server-side state for better scalability +- **Clear naming** - Use descriptive server and tool names +- **Documentation** - Provide clear docstrings for all public interfaces + +### Performance considerations + +- **Use async/await** - For I/O-bound operations +- **Connection pooling** - Reuse database connections via lifespan +- **Caching** - Cache expensive computations where appropriate +- **Batch operations** - Group related operations when possible + +### Security + +- **Input validation** - Validate all tool parameters +- **Error handling** - Don't expose sensitive information in errors +- **Authentication** - Use OAuth 2.1 for protected resources +- **Rate limiting** - Implement rate limiting for expensive operations + +## Next steps + +- **[Learn about tools](tools.md)** - Create powerful LLM-callable functions +- **[Working with resources](resources.md)** - Expose data effectively +- **[Server deployment](running-servers.md)** - Run servers in production +- **[Authentication](authentication.md)** - Secure your servers \ No newline at end of file diff --git a/docs/streamable-http.md b/docs/streamable-http.md new file mode 100644 index 000000000..142dd219a --- /dev/null +++ b/docs/streamable-http.md @@ -0,0 +1,722 @@ +# Streamable HTTP + +Streamable HTTP is the modern transport for MCP servers, designed for production deployments with better scalability, resumability, and flexibility than SSE transport. + +## Overview + +Streamable HTTP offers: + +- **Stateful and stateless modes** - Choose based on your scaling needs +- **Resumable connections** - Clients can reconnect and resume sessions +- **Event sourcing** - Built-in event store for reliability +- **JSON or SSE responses** - Flexible response formats +- **Better performance** - Optimized for high-throughput scenarios + +## Basic usage + +### Simple streamable HTTP server + +```python +from mcp.server.fastmcp import FastMCP + +# Create server +mcp = FastMCP(\"Streamable Server\") + +@mcp.tool() +def calculate(expression: str) -> float: + \"\"\"Safely evaluate mathematical expressions.\"\"\" + # Simple calculator (in production, use a proper math parser) + allowed = set('0123456789+-*/.() ') + if not all(c in allowed for c in expression): + raise ValueError(\"Invalid characters in expression\") + + try: + result = eval(expression) + return float(result) + except Exception as e: + raise ValueError(f\"Cannot evaluate expression: {e}\") + +# Run with streamable HTTP transport +if __name__ == \"__main__\": + mcp.run(transport=\"streamable-http\", host=\"0.0.0.0\", port=8000) +``` + +Access the server at `http://localhost:8000/mcp` + +## Configuration options + +### Stateful vs stateless + +```python +# Stateful server (default) - maintains session state +mcp_stateful = FastMCP(\"Stateful Server\") + +# Stateless server - no session persistence, better for scaling +mcp_stateless = FastMCP(\"Stateless Server\", stateless_http=True) + +# Stateless with JSON responses only (no SSE) +mcp_json = FastMCP(\"JSON Server\", stateless_http=True, json_response=True) +``` + +### Custom paths and ports + +```python +mcp = FastMCP( + \"Custom Server\", + host=\"0.0.0.0\", + port=3001, + mount_path=\"/api/mcp\", # Custom MCP endpoint + sse_path=\"/events\", # Custom SSE endpoint +) + +# Server available at: +# - http://localhost:3001/api/mcp (MCP endpoint) +# - http://localhost:3001/events (SSE endpoint) +``` + +## Client connections + +### HTTP client example + +```python +\"\"\" +Example HTTP client for streamable HTTP servers. +\"\"\" + +import asyncio +import aiohttp +import json + +async def call_mcp_tool(): + \"\"\"Call MCP tool via HTTP.\"\"\" + url = \"http://localhost:8000/mcp\" + + # Initialize connection + init_request = { + \"method\": \"initialize\", + \"params\": { + \"protocolVersion\": \"2025-06-18\", + \"clientInfo\": { + \"name\": \"HTTP Client\", + \"version\": \"1.0.0\" + }, + \"capabilities\": {} + } + } + + async with aiohttp.ClientSession() as session: + # Initialize + async with session.post(url, json=init_request) as response: + init_result = await response.json() + print(f\"Initialize: {init_result}\") + + # List tools + list_request = { + \"method\": \"tools/list\", + \"params\": {} + } + + async with session.post(url, json=list_request) as response: + tools_result = await response.json() + print(f\"Tools: {tools_result}\") + + # Call tool + call_request = { + \"method\": \"tools/call\", + \"params\": { + \"name\": \"calculate\", + \"arguments\": {\"expression\": \"2 + 3 * 4\"} + } + } + + async with session.post(url, json=call_request) as response: + call_result = await response.json() + print(f\"Result: {call_result}\") + +if __name__ == \"__main__\": + asyncio.run(call_mcp_tool()) +``` + +### Using the MCP client library + +```python +\"\"\" +Connect to streamable HTTP server using MCP client library. +\"\"\" + +import asyncio +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + +async def connect_to_server(): + \"\"\"Connect using MCP client library.\"\"\" + + async with streamablehttp_client(\"http://localhost:8000/mcp\") as (read, write, _): + async with ClientSession(read, write) as session: + # Initialize connection + await session.initialize() + + # List available tools + tools = await session.list_tools() + print(f\"Available tools: {[tool.name for tool in tools.tools]}\") + + # Call a tool + result = await session.call_tool(\"calculate\", {\"expression\": \"10 / 2\"}) + content = result.content[0] + if hasattr(content, 'text'): + print(f\"Calculation result: {content.text}\") + +if __name__ == \"__main__\": + asyncio.run(connect_to_server()) +``` + +## Mounting to existing applications + +### Starlette integration + +```python +\"\"\" +Mount multiple MCP servers in a Starlette application. +\"\"\" + +import contextlib +from starlette.applications import Starlette +from starlette.routing import Mount, Route +from starlette.responses import JSONResponse +from mcp.server.fastmcp import FastMCP + +# Create specialized servers +auth_server = FastMCP(\"Auth Server\", stateless_http=True) +data_server = FastMCP(\"Data Server\", stateless_http=True) + +@auth_server.tool() +def login(username: str, password: str) -> dict: + \"\"\"Authenticate user.\"\"\" + # Simple auth (use proper authentication in production) + if username == \"admin\" and password == \"secret\": + return {\"token\": \"auth-token-123\", \"expires\": 3600} + raise ValueError(\"Invalid credentials\") + +@data_server.tool() +def get_data(query: str) -> list[dict]: + \"\"\"Retrieve data based on query.\"\"\" + # Mock data + return [{\"id\": 1, \"data\": f\"Result for {query}\"}] + +# Health check endpoint +async def health_check(request): + return JSONResponse({\"status\": \"healthy\"}) + +# Combined lifespan manager +@contextlib.asynccontextmanager +async def lifespan(app): + async with contextlib.AsyncExitStack() as stack: + await stack.enter_async_context(auth_server.session_manager.run()) + await stack.enter_async_context(data_server.session_manager.run()) + yield + +# Create Starlette app +app = Starlette( + routes=[ + Route(\"/health\", health_check), + Mount(\"/auth\", auth_server.streamable_http_app()), + Mount(\"/data\", data_server.streamable_http_app()), + ], + lifespan=lifespan +) + +# Run with: uvicorn app:app --host 0.0.0.0 --port 8000 +``` + +### FastAPI integration + +```python +\"\"\" +Integrate MCP server with FastAPI. +\"\"\" + +from fastapi import FastAPI +from mcp.server.fastmcp import FastMCP + +# Create FastAPI app +app = FastAPI(title=\"API with MCP\") + +# Create MCP server +mcp = FastMCP(\"FastAPI MCP\", stateless_http=True) + +@mcp.tool() +def process_request(data: str) -> dict: + \"\"\"Process API request data.\"\"\" + return {\"processed\": data, \"length\": len(data)} + +# Regular FastAPI endpoint +@app.get(\"/\") +async def root(): + return {\"message\": \"FastAPI with MCP integration\"} + +# Mount MCP server +app.mount(\"/mcp\", mcp.streamable_http_app()) + +# Startup event +@app.on_event(\"startup\") +async def startup(): + await mcp.session_manager.start() + +# Shutdown event +@app.on_event(\"shutdown\") +async def shutdown(): + await mcp.session_manager.stop() +``` + +## Advanced configuration + +### Event store configuration + +```python +\"\"\" +Server with custom event store configuration. +\"\"\" + +from mcp.server.fastmcp import FastMCP +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +import aioredis + +@asynccontextmanager +async def redis_lifespan(server: FastMCP) -> AsyncIterator[dict]: + \"\"\"Configure Redis for event storage.\"\"\" + redis = aioredis.from_url(\"redis://localhost:6379\") + try: + yield {\"redis\": redis} + finally: + await redis.close() + +# Create server with event store +mcp = FastMCP( + \"Event Store Server\", + lifespan=redis_lifespan, + stateless_http=False # Stateful for event sourcing +) + +@mcp.tool() +async def store_event(event_type: str, data: dict, ctx) -> str: + \"\"\"Store an event with Redis backend.\"\"\" + import json + import time + + redis = ctx.request_context.lifespan_context[\"redis\"] + + event = { + \"type\": event_type, + \"data\": data, + \"timestamp\": time.time(), + \"id\": f\"event_{hash(str(data)) % 10000:04d}\" + } + + # Store event in Redis + await redis.lpush(\"events\", json.dumps(event)) + await redis.ltrim(\"events\", 0, 999) # Keep last 1000 events + + return event[\"id\"] +``` + +### Custom middleware + +```python +\"\"\" +Server with custom middleware for logging and authentication. +\"\"\" + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response +import time +import logging + +logger = logging.getLogger(\"mcp.middleware\") + +class LoggingMiddleware(BaseHTTPMiddleware): + \"\"\"Middleware to log all requests.\"\"\" + + async def dispatch(self, request: Request, call_next): + start_time = time.time() + + # Log request + logger.info(f\"Request: {request.method} {request.url.path}\") + + response = await call_next(request) + + # Log response + duration = time.time() - start_time + logger.info(f\"Response: {response.status_code} ({duration:.3f}s)\") + + return response + +class AuthMiddleware(BaseHTTPMiddleware): + \"\"\"Simple API key authentication middleware.\"\"\" + + async def dispatch(self, request: Request, call_next): + # Skip auth for health checks + if request.url.path == \"/health\": + return await call_next(request) + + # Check API key + api_key = request.headers.get(\"X-API-Key\") + if not api_key or api_key != \"secret-key-123\": + return Response(\"Unauthorized\", status_code=401) + + return await call_next(request) + +# Create server with middleware +mcp = FastMCP(\"Middleware Server\") + +@mcp.tool() +def protected_operation() -> str: + \"\"\"Operation that requires authentication.\"\"\" + return \"This operation is protected by middleware\" + +# Add middleware to the ASGI app +app = mcp.streamable_http_app() +app.add_middleware(LoggingMiddleware) +app.add_middleware(AuthMiddleware) + +if __name__ == \"__main__\": + # Custom ASGI server setup + import uvicorn + uvicorn.run(app, host=\"0.0.0.0\", port=8000) +``` + +## Performance optimization + +### Connection pooling + +```python +\"\"\" +High-performance server with connection pooling. +\"\"\" + +import asyncpg +import aioredis +from contextlib import asynccontextmanager +from collections.abc import AsyncIterator +from dataclasses import dataclass + +@dataclass +class PerformanceContext: + db_pool: asyncpg.Pool + redis_pool: aioredis.ConnectionPool + +@asynccontextmanager +async def performance_lifespan(server: FastMCP) -> AsyncIterator[PerformanceContext]: + \"\"\"High-performance lifespan with connection pools.\"\"\" + + # Database connection pool + db_pool = await asyncpg.create_pool( + \"postgresql://user:pass@localhost/db\", + min_size=10, + max_size=50, + max_queries=50000, + max_inactive_connection_lifetime=300, + ) + + # Redis connection pool + redis_pool = aioredis.ConnectionPool.from_url( + \"redis://localhost:6379\", + max_connections=20 + ) + + try: + yield PerformanceContext(db_pool=db_pool, redis_pool=redis_pool) + finally: + await db_pool.close() + redis_pool.disconnect() + +# Optimized server configuration +mcp = FastMCP( + \"High Performance Server\", + lifespan=performance_lifespan, + stateless_http=True, # Better for horizontal scaling + json_response=True, # Disable SSE for pure HTTP + host=\"0.0.0.0\", + port=8000 +) + +@mcp.tool() +async def fast_query(sql: str, ctx) -> list[dict]: + \"\"\"Execute database query using connection pool.\"\"\" + context = ctx.request_context.lifespan_context + + async with context.db_pool.acquire() as conn: + rows = await conn.fetch(sql) + return [dict(row) for row in rows] + +@mcp.tool() +async def cache_operation(key: str, value: str, ctx) -> str: + \"\"\"Cache operation using Redis pool.\"\"\" + context = ctx.request_context.lifespan_context + + redis = aioredis.Redis(connection_pool=context.redis_pool) + await redis.set(key, value, ex=3600) # 1 hour expiration + + return f\"Cached {key} = {value}\" +``` + +### Load balancing setup + +```yaml +# docker-compose.yml for load-balanced setup +version: '3.8' + +services: + nginx: + image: nginx:alpine + ports: + - \"80:80\" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf + depends_on: + - mcp-server-1 + - mcp-server-2 + + mcp-server-1: + build: . + environment: + - INSTANCE_ID=server-1 + - PORT=8000 + + mcp-server-2: + build: . + environment: + - INSTANCE_ID=server-2 + - PORT=8000 + + redis: + image: redis:alpine + + postgres: + image: postgres:15 + environment: + POSTGRES_DB: mcpdb + POSTGRES_USER: mcpuser + POSTGRES_PASSWORD: mcppass +``` + +Nginx configuration for load balancing: + +```nginx +# nginx.conf +events { + worker_connections 1024; +} + +http { + upstream mcp_servers { + server mcp-server-1:8000; + server mcp-server-2:8000; + } + + server { + listen 80; + + location /mcp { + proxy_pass http://mcp_servers; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection \"upgrade\"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + } +} +``` + +## Monitoring and debugging + +### Health checks and metrics + +```python +@mcp.tool() +async def server_metrics() -> dict: + \"\"\"Get server performance metrics.\"\"\" + import psutil + import time + + process = psutil.Process() + + return { + \"memory\": { + \"rss\": process.memory_info().rss, + \"vms\": process.memory_info().vms, + \"percent\": process.memory_percent() + }, + \"cpu\": { + \"percent\": process.cpu_percent(), + \"times\": process.cpu_times()._asdict() + }, + \"connections\": len(process.connections()), + \"uptime\": time.time() - process.create_time(), + \"threads\": process.num_threads() + } + +@mcp.tool() +async def connection_info(ctx) -> dict: + \"\"\"Get information about current connection.\"\"\" + return { + \"request_id\": ctx.request_id, + \"client_id\": ctx.client_id, + \"server_name\": ctx.fastmcp.name, + \"transport\": \"streamable-http\", + \"stateless\": ctx.fastmcp.settings.stateless_http + } +``` + +### Request tracing + +```python +import uuid +from starlette.middleware.base import BaseHTTPMiddleware + +class TracingMiddleware(BaseHTTPMiddleware): + \"\"\"Add tracing to all requests.\"\"\" + + async def dispatch(self, request: Request, call_next): + # Generate trace ID + trace_id = str(uuid.uuid4()) + request.state.trace_id = trace_id + + # Add to response headers + response = await call_next(request) + response.headers[\"X-Trace-ID\"] = trace_id + + return response + +@mcp.tool() +async def traced_operation(data: str, ctx) -> dict: + \"\"\"Operation with distributed tracing.\"\"\" + # In a real implementation, you'd get trace_id from request context + trace_id = f\"trace_{hash(data) % 10000:04d}\" + + await ctx.info(f\"[{trace_id}] Processing operation\") + + result = {\"processed\": data, \"trace_id\": trace_id} + + await ctx.info(f\"[{trace_id}] Operation completed\") + + return result +``` + +## Testing streamable HTTP servers + +### Integration testing + +```python +\"\"\" +Integration tests for streamable HTTP server. +\"\"\" + +import pytest +import asyncio +import aiohttp +from mcp.server.fastmcp import FastMCP + +@pytest.fixture +async def test_server(): + \"\"\"Create test server.\"\"\" + mcp = FastMCP(\"Test Server\") + + @mcp.tool() + def test_tool(value: str) -> str: + return f\"Test: {value}\" + + # Start server in background + server_task = asyncio.create_task( + mcp.run_async(transport=\"streamable-http\", port=8999) + ) + + # Wait for server to start + await asyncio.sleep(0.1) + + yield \"http://localhost:8999/mcp\" + + # Cleanup + server_task.cancel() + try: + await server_task + except asyncio.CancelledError: + pass + +@pytest.mark.asyncio +async def test_server_connection(test_server): + \"\"\"Test basic server connectivity.\"\"\" + url = test_server + + async with aiohttp.ClientSession() as session: + # Test initialization + init_request = { + \"method\": \"initialize\", + \"params\": { + \"protocolVersion\": \"2025-06-18\", + \"clientInfo\": {\"name\": \"Test Client\", \"version\": \"1.0.0\"}, + \"capabilities\": {} + } + } + + async with session.post(url, json=init_request) as response: + assert response.status == 200 + result = await response.json() + assert \"result\" in result + +@pytest.mark.asyncio +async def test_tool_call(test_server): + \"\"\"Test tool invocation.\"\"\" + url = test_server + + async with aiohttp.ClientSession() as session: + # Call tool + call_request = { + \"method\": \"tools/call\", + \"params\": { + \"name\": \"test_tool\", + \"arguments\": {\"value\": \"hello\"} + } + } + + async with session.post(url, json=call_request) as response: + assert response.status == 200 + result = await response.json() + assert \"result\" in result +``` + +## Best practices + +### Deployment guidelines + +- **Use stateless mode** for horizontal scaling +- **Enable connection pooling** for database and cache operations +- **Implement health checks** for load balancer integration +- **Add proper logging** with structured output and trace IDs +- **Use reverse proxy** (nginx/Apache) for SSL termination and load balancing + +### Performance tips + +- **Choose stateless mode** for better scalability +- **Use connection pools** for external services +- **Implement caching** for expensive operations +- **Monitor resource usage** with metrics endpoints +- **Optimize database queries** and use proper indexing + +### Security considerations + +- **Use HTTPS** in production +- **Implement authentication** middleware +- **Validate inputs** thoroughly +- **Rate limit** requests to prevent abuse +- **Log security events** for monitoring + +## Next steps + +- **[ASGI integration](asgi-integration.md)** - Integrate with web frameworks +- **[Running servers](running-servers.md)** - Production deployment strategies +- **[Authentication](authentication.md)** - Secure your HTTP endpoints +- **[Client development](writing-clients.md)** - Build HTTP clients \ No newline at end of file diff --git a/docs/structured-output.md b/docs/structured-output.md new file mode 100644 index 000000000..47214beed --- /dev/null +++ b/docs/structured-output.md @@ -0,0 +1,1477 @@ +# Structured output + +Learn how to create structured, typed outputs from your MCP tools using Pydantic models, TypedDict, and other approaches for better data exchange. + +## Overview + +Structured output provides: + +- **Type safety** - Ensure outputs match expected schemas +- **Data validation** - Automatic validation of output data +- **Documentation** - Self-documenting APIs with clear schemas +- **Client compatibility** - Easier parsing and processing for clients +- **Error prevention** - Catch output errors before they reach clients + +## Pydantic models + +### Basic structured models + +```python +""" +Structured output using Pydantic models. +""" + +from pydantic import BaseModel, Field, validator +from typing import List, Optional, Dict, Any +from datetime import datetime +from enum import Enum +from mcp.server.fastmcp import FastMCP + +# Create server +mcp = FastMCP("Structured Output Server") + +# Define output models +class TaskStatus(str, Enum): + """Task status enumeration.""" + PENDING = "pending" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + FAILED = "failed" + +class Task(BaseModel): + """Task model with structured output.""" + id: str = Field(..., description="Unique task identifier") + title: str = Field(..., min_length=1, max_length=200, description="Task title") + description: Optional[str] = Field(None, description="Task description") + status: TaskStatus = Field(default=TaskStatus.PENDING, description="Current task status") + priority: int = Field(default=1, ge=1, le=5, description="Task priority (1-5)") + created_at: datetime = Field(default_factory=datetime.now, description="Creation timestamp") + updated_at: Optional[datetime] = Field(None, description="Last update timestamp") + tags: List[str] = Field(default_factory=list, description="Task tags") + metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata") + + @validator('tags') + def validate_tags(cls, v): + """Validate tags are not empty strings.""" + return [tag.strip() for tag in v if tag.strip()] + + @validator('updated_at', always=True) + def set_updated_at(cls, v, values): + """Set updated_at when status changes.""" + return v or datetime.now() + +class TaskList(BaseModel): + """List of tasks with metadata.""" + tasks: List[Task] = Field(..., description="List of tasks") + total_count: int = Field(..., description="Total number of tasks") + page: int = Field(default=1, ge=1, description="Current page number") + page_size: int = Field(default=10, ge=1, le=100, description="Number of tasks per page") + has_more: bool = Field(..., description="Whether there are more tasks") + +class TaskStatistics(BaseModel): + """Task statistics model.""" + total_tasks: int = Field(..., ge=0, description="Total number of tasks") + by_status: Dict[TaskStatus, int] = Field(..., description="Task count by status") + by_priority: Dict[int, int] = Field(..., description="Task count by priority") + average_completion_time: Optional[float] = Field(None, description="Average completion time in hours") + + @validator('by_status', 'by_priority') + def ensure_non_negative_counts(cls, v): + """Ensure all counts are non-negative.""" + return {k: max(0, count) for k, count in v.items()} + +# Tool implementations with structured output +@mcp.tool() +def create_task( + title: str, + description: str = "", + priority: int = 1, + tags: List[str] = None +) -> Task: + """Create a new task with structured output.""" + import uuid + + task = Task( + id=str(uuid.uuid4()), + title=title, + description=description, + priority=priority, + tags=tags or [] + ) + + return task + +@mcp.tool() +def list_tasks( + page: int = 1, + page_size: int = 10, + status_filter: Optional[TaskStatus] = None +) -> TaskList: + """List tasks with pagination and filtering.""" + import uuid + + # Mock task data + all_tasks = [] + for i in range(25): # Mock 25 tasks + task = Task( + id=str(uuid.uuid4()), + title=f"Task {i + 1}", + description=f"Description for task {i + 1}", + status=list(TaskStatus)[i % 4], + priority=(i % 5) + 1, + tags=[f"tag-{i % 3}", f"category-{i % 2}"] + ) + all_tasks.append(task) + + # Apply status filter + if status_filter: + filtered_tasks = [t for t in all_tasks if t.status == status_filter] + else: + filtered_tasks = all_tasks + + # Apply pagination + start_idx = (page - 1) * page_size + end_idx = start_idx + page_size + page_tasks = filtered_tasks[start_idx:end_idx] + + return TaskList( + tasks=page_tasks, + total_count=len(filtered_tasks), + page=page, + page_size=page_size, + has_more=end_idx < len(filtered_tasks) + ) + +@mcp.tool() +def get_task_statistics() -> TaskStatistics: + """Get task statistics with structured output.""" + # Mock statistics calculation + total_tasks = 25 + + by_status = { + TaskStatus.PENDING: 8, + TaskStatus.IN_PROGRESS: 5, + TaskStatus.COMPLETED: 10, + TaskStatus.FAILED: 2 + } + + by_priority = {1: 5, 2: 6, 3: 7, 4: 4, 5: 3} + + return TaskStatistics( + total_tasks=total_tasks, + by_status=by_status, + by_priority=by_priority, + average_completion_time=24.5 + ) + +if __name__ == "__main__": + mcp.run() +``` + +### Nested structured models + +```python +""" +Complex nested structured models. +""" + +from pydantic import BaseModel, Field, validator +from typing import List, Optional, Dict, Any, Union +from datetime import datetime, date +from decimal import Decimal + +class Address(BaseModel): + """Address model.""" + street: str = Field(..., description="Street address") + city: str = Field(..., description="City name") + state: str = Field(..., description="State or province") + postal_code: str = Field(..., description="Postal/ZIP code") + country: str = Field(default="US", description="Country code") + + @validator('postal_code') + def validate_postal_code(cls, v, values): + """Validate postal code format based on country.""" + country = values.get('country', 'US') + if country == 'US': + import re + if not re.match(r'^\\d{5}(-\\d{4})?$', v): + raise ValueError('Invalid US postal code format') + return v + +class ContactInfo(BaseModel): + """Contact information model.""" + email: Optional[str] = Field(None, description="Email address") + phone: Optional[str] = Field(None, description="Phone number") + website: Optional[str] = Field(None, description="Website URL") + + @validator('email') + def validate_email(cls, v): + """Validate email format.""" + if v: + import re + if not re.match(r'^[^@]+@[^@]+\\.[^@]+$', v): + raise ValueError('Invalid email format') + return v + +class Customer(BaseModel): + """Customer model with nested structures.""" + id: str = Field(..., description="Customer ID") + name: str = Field(..., description="Customer name") + email: str = Field(..., description="Primary email") + addresses: List[Address] = Field(default_factory=list, description="Customer addresses") + contact_info: Optional[ContactInfo] = Field(None, description="Additional contact info") + created_at: datetime = Field(default_factory=datetime.now, description="Account creation time") + is_active: bool = Field(default=True, description="Account status") + metadata: Dict[str, Any] = Field(default_factory=dict, description="Custom metadata") + +class OrderItem(BaseModel): + """Order item model.""" + product_id: str = Field(..., description="Product identifier") + product_name: str = Field(..., description="Product name") + quantity: int = Field(..., ge=1, description="Item quantity") + unit_price: Decimal = Field(..., ge=0, description="Price per unit") + discount_percent: float = Field(default=0.0, ge=0, le=100, description="Discount percentage") + + @property + def subtotal(self) -> Decimal: + """Calculate item subtotal.""" + return self.unit_price * self.quantity + + @property + def discount_amount(self) -> Decimal: + """Calculate discount amount.""" + return self.subtotal * Decimal(self.discount_percent / 100) + + @property + def total(self) -> Decimal: + """Calculate item total after discount.""" + return self.subtotal - self.discount_amount + +class Order(BaseModel): + """Order model with complex calculations.""" + id: str = Field(..., description="Order ID") + customer: Customer = Field(..., description="Customer information") + items: List[OrderItem] = Field(..., min_items=1, description="Order items") + order_date: date = Field(default_factory=date.today, description="Order date") + shipping_address: Address = Field(..., description="Shipping address") + billing_address: Optional[Address] = Field(None, description="Billing address") + notes: Optional[str] = Field(None, description="Order notes") + + @validator('billing_address', always=True) + def set_billing_address(cls, v, values): + """Use shipping address as billing if not provided.""" + return v or values.get('shipping_address') + + @property + def subtotal(self) -> Decimal: + """Calculate order subtotal.""" + return sum(item.subtotal for item in self.items) + + @property + def total_discount(self) -> Decimal: + """Calculate total discount amount.""" + return sum(item.discount_amount for item in self.items) + + @property + def total(self) -> Decimal: + """Calculate order total.""" + return sum(item.total for item in self.items) + +class OrderSummary(BaseModel): + """Order summary with aggregated data.""" + order_count: int = Field(..., description="Total number of orders") + total_revenue: Decimal = Field(..., description="Total revenue") + average_order_value: Decimal = Field(..., description="Average order value") + top_customers: List[Customer] = Field(..., description="Top customers by order value") + recent_orders: List[Order] = Field(..., description="Most recent orders") + +# Tools using nested models +@mcp.tool() +def create_customer( + name: str, + email: str, + street: str, + city: str, + state: str, + postal_code: str, + country: str = "US" +) -> Customer: + """Create a new customer with address.""" + import uuid + + address = Address( + street=street, + city=city, + state=state, + postal_code=postal_code, + country=country + ) + + customer = Customer( + id=str(uuid.uuid4()), + name=name, + email=email, + addresses=[address] + ) + + return customer + +@mcp.tool() +def create_order( + customer_data: Dict[str, Any], + items_data: List[Dict[str, Any]], + shipping_address_data: Dict[str, Any], + notes: str = "" +) -> Order: + """Create a new order with complex nested data.""" + import uuid + from decimal import Decimal + + # Parse customer data + customer = Customer(**customer_data) + + # Parse shipping address + shipping_address = Address(**shipping_address_data) + + # Parse order items + items = [] + for item_data in items_data: + # Convert price to Decimal + item_data['unit_price'] = Decimal(str(item_data['unit_price'])) + items.append(OrderItem(**item_data)) + + order = Order( + id=str(uuid.uuid4()), + customer=customer, + items=items, + shipping_address=shipping_address, + notes=notes + ) + + return order + +@mcp.tool() +def get_order_summary(days: int = 30) -> OrderSummary: + """Get order summary for the specified number of days.""" + from decimal import Decimal + import uuid + + # Mock data generation + mock_customers = [] + mock_orders = [] + + for i in range(5): + customer = Customer( + id=str(uuid.uuid4()), + name=f"Customer {i+1}", + email=f"customer{i+1}@example.com" + ) + mock_customers.append(customer) + + # Create mock order for this customer + order_items = [ + OrderItem( + product_id=str(uuid.uuid4()), + product_name=f"Product {j+1}", + quantity=j+1, + unit_price=Decimal("19.99"), + discount_percent=5.0 if j > 0 else 0.0 + ) + for j in range(2) + ] + + shipping_address = Address( + street=f"{100 + i} Main St", + city="Example City", + state="CA", + postal_code="90210", + country="US" + ) + + order = Order( + id=str(uuid.uuid4()), + customer=customer, + items=order_items, + shipping_address=shipping_address + ) + mock_orders.append(order) + + total_revenue = sum(order.total for order in mock_orders) + average_order_value = total_revenue / len(mock_orders) + + return OrderSummary( + order_count=len(mock_orders), + total_revenue=total_revenue, + average_order_value=average_order_value, + top_customers=mock_customers[:3], + recent_orders=mock_orders[:3] + ) + +if __name__ == "__main__": + mcp.run() +``` + +## TypedDict approach + +### Using TypedDict for structured output + +```python +""" +Structured output using TypedDict for Python 3.8+ compatibility. +""" + +from typing import TypedDict, List, Optional, Dict, Any, Union, Literal +from typing_extensions import NotRequired +from datetime import datetime +import json + +# Define TypedDict schemas +class UserProfile(TypedDict): + """User profile structure.""" + id: str + username: str + email: str + full_name: str + is_active: bool + created_at: str # ISO format datetime + updated_at: NotRequired[str] # Optional field + preferences: Dict[str, Any] + +class PostStats(TypedDict): + """Post statistics structure.""" + views: int + likes: int + comments: int + shares: int + engagement_rate: float + +class Post(TypedDict): + """Blog post structure.""" + id: str + title: str + content: str + author: UserProfile + status: Literal["draft", "published", "archived"] + tags: List[str] + stats: PostStats + created_at: str + published_at: NotRequired[str] + +class PostListResponse(TypedDict): + """Post list response structure.""" + posts: List[Post] + pagination: Dict[str, Union[int, bool]] + filters_applied: Dict[str, Any] + +class AnalyticsData(TypedDict): + """Analytics data structure.""" + period: str + total_posts: int + total_views: int + total_engagement: int + top_posts: List[Post] + user_stats: Dict[str, Any] + +# Validation functions for TypedDict +def validate_user_profile(data: Dict[str, Any]) -> UserProfile: + """Validate and create UserProfile.""" + required_fields = ['id', 'username', 'email', 'full_name', 'is_active', 'created_at', 'preferences'] + + for field in required_fields: + if field not in data: + raise ValueError(f"Missing required field: {field}") + + # Type validations + if not isinstance(data['is_active'], bool): + raise ValueError("is_active must be boolean") + + if not isinstance(data['preferences'], dict): + raise ValueError("preferences must be a dictionary") + + # Validate email format + import re + if not re.match(r'^[^@]+@[^@]+\\.[^@]+$', data['email']): + raise ValueError("Invalid email format") + + return UserProfile( + id=str(data['id']), + username=str(data['username']), + email=str(data['email']), + full_name=str(data['full_name']), + is_active=bool(data['is_active']), + created_at=str(data['created_at']), + preferences=dict(data['preferences']), + **{k: v for k, v in data.items() if k in ['updated_at'] and v is not None} + ) + +def validate_post_stats(data: Dict[str, Any]) -> PostStats: + """Validate and create PostStats.""" + required_fields = ['views', 'likes', 'comments', 'shares', 'engagement_rate'] + + for field in required_fields: + if field not in data: + raise ValueError(f"Missing required field: {field}") + + # Ensure non-negative values + for field in ['views', 'likes', 'comments', 'shares']: + if not isinstance(data[field], int) or data[field] < 0: + raise ValueError(f"{field} must be a non-negative integer") + + if not isinstance(data['engagement_rate'], (int, float)) or data['engagement_rate'] < 0: + raise ValueError("engagement_rate must be a non-negative number") + + return PostStats( + views=int(data['views']), + likes=int(data['likes']), + comments=int(data['comments']), + shares=int(data['shares']), + engagement_rate=float(data['engagement_rate']) + ) + +def validate_post(data: Dict[str, Any]) -> Post: + """Validate and create Post.""" + required_fields = ['id', 'title', 'content', 'author', 'status', 'tags', 'stats', 'created_at'] + + for field in required_fields: + if field not in data: + raise ValueError(f"Missing required field: {field}") + + # Validate status + valid_statuses = ['draft', 'published', 'archived'] + if data['status'] not in valid_statuses: + raise ValueError(f"status must be one of: {valid_statuses}") + + # Validate tags + if not isinstance(data['tags'], list): + raise ValueError("tags must be a list") + + # Validate nested structures + author = validate_user_profile(data['author']) + stats = validate_post_stats(data['stats']) + + result = Post( + id=str(data['id']), + title=str(data['title']), + content=str(data['content']), + author=author, + status=data['status'], # Already validated + tags=[str(tag) for tag in data['tags']], + stats=stats, + created_at=str(data['created_at']) + ) + + # Add optional fields + if 'published_at' in data and data['published_at'] is not None: + result['published_at'] = str(data['published_at']) + + return result + +# MCP tools using TypedDict +@mcp.tool() +def create_user( + username: str, + email: str, + full_name: str, + preferences: Dict[str, Any] = None +) -> UserProfile: + """Create a new user with structured output.""" + import uuid + from datetime import datetime + + user_data = { + 'id': str(uuid.uuid4()), + 'username': username, + 'email': email, + 'full_name': full_name, + 'is_active': True, + 'created_at': datetime.now().isoformat(), + 'preferences': preferences or {} + } + + return validate_user_profile(user_data) + +@mcp.tool() +def create_post( + title: str, + content: str, + author_id: str, + tags: List[str] = None, + status: str = "draft" +) -> Post: + """Create a new blog post.""" + import uuid + from datetime import datetime + + # Mock author data + author_data = { + 'id': author_id, + 'username': f'user_{author_id[:8]}', + 'email': f'user_{author_id[:8]}@example.com', + 'full_name': 'Example User', + 'is_active': True, + 'created_at': datetime.now().isoformat(), + 'preferences': {'theme': 'light', 'notifications': True} + } + + stats_data = { + 'views': 0, + 'likes': 0, + 'comments': 0, + 'shares': 0, + 'engagement_rate': 0.0 + } + + post_data = { + 'id': str(uuid.uuid4()), + 'title': title, + 'content': content, + 'author': author_data, + 'status': status, + 'tags': tags or [], + 'stats': stats_data, + 'created_at': datetime.now().isoformat() + } + + if status == 'published': + post_data['published_at'] = datetime.now().isoformat() + + return validate_post(post_data) + +@mcp.tool() +def list_posts( + status: str = "published", + page: int = 1, + page_size: int = 10, + tag_filter: str = None +) -> PostListResponse: + """List posts with filtering and pagination.""" + import uuid + from datetime import datetime + + # Generate mock posts + posts = [] + for i in range(page_size): + author_data = { + 'id': str(uuid.uuid4()), + 'username': f'author_{i}', + 'email': f'author_{i}@example.com', + 'full_name': f'Author {i}', + 'is_active': True, + 'created_at': datetime.now().isoformat(), + 'preferences': {} + } + + stats_data = { + 'views': (i + 1) * 100, + 'likes': (i + 1) * 10, + 'comments': (i + 1) * 2, + 'shares': i + 1, + 'engagement_rate': min(50.0, (i + 1) * 2.5) + } + + post_tags = [f'tag-{i % 3}', f'category-{i % 2}'] + if tag_filter: + post_tags.append(tag_filter) + + post_data = { + 'id': str(uuid.uuid4()), + 'title': f'Post {i + 1}', + 'content': f'Content for post {i + 1}...', + 'author': author_data, + 'status': status, + 'tags': post_tags, + 'stats': stats_data, + 'created_at': datetime.now().isoformat() + } + + if status == 'published': + post_data['published_at'] = datetime.now().isoformat() + + posts.append(validate_post(post_data)) + + return PostListResponse( + posts=posts, + pagination={ + 'page': page, + 'page_size': page_size, + 'total_pages': 5, # Mock total pages + 'has_next': page < 5, + 'has_prev': page > 1 + }, + filters_applied={ + 'status': status, + 'tag_filter': tag_filter + } + ) + +@mcp.tool() +def get_analytics(period: str = "month") -> AnalyticsData: + """Get analytics data for the specified period.""" + # Generate mock analytics + total_posts = 50 + total_views = 10000 + total_engagement = 2500 + + # Create mock top posts + top_posts = [] + for i in range(3): + author_data = { + 'id': str(uuid.uuid4()), + 'username': f'top_author_{i}', + 'email': f'top_author_{i}@example.com', + 'full_name': f'Top Author {i}', + 'is_active': True, + 'created_at': datetime.now().isoformat(), + 'preferences': {} + } + + stats_data = { + 'views': 1000 - (i * 100), + 'likes': 100 - (i * 10), + 'comments': 50 - (i * 5), + 'shares': 20 - (i * 2), + 'engagement_rate': 15.0 - (i * 2.0) + } + + post_data = { + 'id': str(uuid.uuid4()), + 'title': f'Top Post {i + 1}', + 'content': f'Content for top post {i + 1}...', + 'author': author_data, + 'status': 'published', + 'tags': ['trending', f'category-{i}'], + 'stats': stats_data, + 'created_at': datetime.now().isoformat(), + 'published_at': datetime.now().isoformat() + } + + top_posts.append(validate_post(post_data)) + + return AnalyticsData( + period=period, + total_posts=total_posts, + total_views=total_views, + total_engagement=total_engagement, + top_posts=top_posts, + user_stats={ + 'total_users': 500, + 'active_users': 350, + 'new_users_this_period': 25 + } + ) + +if __name__ == "__main__": + mcp.run() +``` + +## JSON Schema validation + +### Schema-based validation + +```python +""" +JSON Schema-based structured output validation. +""" + +import json +import jsonschema +from typing import Any, Dict, List +from jsonschema import validate, ValidationError + +# Define JSON schemas +USER_SCHEMA = { + "type": "object", + "properties": { + "id": {"type": "string", "pattern": "^[a-f0-9-]{36}$"}, + "name": {"type": "string", "minLength": 1, "maxLength": 100}, + "email": {"type": "string", "format": "email"}, + "age": {"type": "integer", "minimum": 0, "maximum": 150}, + "preferences": { + "type": "object", + "properties": { + "theme": {"type": "string", "enum": ["light", "dark"]}, + "notifications": {"type": "boolean"}, + "language": {"type": "string", "pattern": "^[a-z]{2}$"} + }, + "additionalProperties": False + }, + "roles": { + "type": "array", + "items": {"type": "string", "enum": ["user", "admin", "moderator"]}, + "uniqueItems": True + } + }, + "required": ["id", "name", "email", "age"], + "additionalProperties": False +} + +PRODUCT_SCHEMA = { + "type": "object", + "properties": { + "id": {"type": "string"}, + "name": {"type": "string", "minLength": 1}, + "description": {"type": "string"}, + "price": {"type": "number", "minimum": 0}, + "currency": {"type": "string", "pattern": "^[A-Z]{3}$"}, + "category": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"}, + "parent_id": {"type": ["string", "null"]} + }, + "required": ["id", "name"] + }, + "tags": { + "type": "array", + "items": {"type": "string"}, + "maxItems": 10 + }, + "in_stock": {"type": "boolean"}, + "stock_quantity": {"type": "integer", "minimum": 0}, + "created_at": {"type": "string", "format": "date-time"}, + "updated_at": {"type": "string", "format": "date-time"} + }, + "required": ["id", "name", "price", "currency", "category", "in_stock"], + "additionalProperties": False +} + +ORDER_SCHEMA = { + "type": "object", + "properties": { + "id": {"type": "string"}, + "customer": {"$ref": "#/definitions/user"}, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "product": {"$ref": "#/definitions/product"}, + "quantity": {"type": "integer", "minimum": 1}, + "unit_price": {"type": "number", "minimum": 0}, + "discount": {"type": "number", "minimum": 0, "maximum": 1} + }, + "required": ["product", "quantity", "unit_price"] + }, + "minItems": 1 + }, + "status": {"type": "string", "enum": ["pending", "processing", "shipped", "delivered", "cancelled"]}, + "total_amount": {"type": "number", "minimum": 0}, + "currency": {"type": "string", "pattern": "^[A-Z]{3}$"}, + "created_at": {"type": "string", "format": "date-time"}, + "shipping_address": { + "type": "object", + "properties": { + "street": {"type": "string"}, + "city": {"type": "string"}, + "state": {"type": "string"}, + "postal_code": {"type": "string"}, + "country": {"type": "string", "pattern": "^[A-Z]{2}$"} + }, + "required": ["street", "city", "state", "postal_code", "country"] + } + }, + "required": ["id", "customer", "items", "status", "total_amount", "currency", "created_at"], + "definitions": { + "user": USER_SCHEMA, + "product": PRODUCT_SCHEMA + } +} + +class SchemaValidator: + """JSON Schema validator for structured output.""" + + def __init__(self): + self.schemas = { + 'user': USER_SCHEMA, + 'product': PRODUCT_SCHEMA, + 'order': ORDER_SCHEMA + } + + # Validate schemas themselves + for name, schema in self.schemas.items(): + try: + jsonschema.Draft7Validator.check_schema(schema) + except jsonschema.SchemaError as e: + raise ValueError(f"Invalid schema '{name}': {e}") + + def validate_output(self, data: Any, schema_name: str) -> Dict[str, Any]: + """Validate output data against schema.""" + if schema_name not in self.schemas: + raise ValueError(f"Unknown schema: {schema_name}") + + schema = self.schemas[schema_name] + + try: + validate(instance=data, schema=schema) + return {"valid": True, "data": data} + except ValidationError as e: + return { + "valid": False, + "error": str(e.message), + "path": list(e.absolute_path), + "schema_path": list(e.schema_path) + } + + def validate_and_clean(self, data: Any, schema_name: str) -> Dict[str, Any]: + """Validate and clean data, removing invalid fields.""" + validation_result = self.validate_output(data, schema_name) + + if validation_result["valid"]: + return validation_result + + # Attempt to clean data + cleaned_data = self._clean_data(data, self.schemas[schema_name]) + + # Try validation again + try: + validate(instance=cleaned_data, schema=self.schemas[schema_name]) + return {"valid": True, "data": cleaned_data, "cleaned": True} + except ValidationError as e: + return { + "valid": False, + "error": str(e.message), + "path": list(e.absolute_path), + "schema_path": list(e.schema_path), + "attempted_cleaning": True + } + + def _clean_data(self, data: Any, schema: Dict[str, Any]) -> Any: + """Clean data by removing invalid fields and converting types.""" + if not isinstance(data, dict) or schema.get("type") != "object": + return data + + cleaned = {} + properties = schema.get("properties", {}) + + for key, value in data.items(): + if key in properties: + prop_schema = properties[key] + cleaned_value = self._clean_value(value, prop_schema) + if cleaned_value is not None: + cleaned[key] = cleaned_value + + return cleaned + + def _clean_value(self, value: Any, prop_schema: Dict[str, Any]) -> Any: + """Clean individual value based on property schema.""" + prop_type = prop_schema.get("type") + + if prop_type == "string": + try: + return str(value) + except: + return None + elif prop_type == "integer": + try: + return int(value) + except: + return None + elif prop_type == "number": + try: + return float(value) + except: + return None + elif prop_type == "boolean": + if isinstance(value, bool): + return value + elif isinstance(value, str): + return value.lower() in ('true', '1', 'yes', 'on') + else: + return bool(value) + elif prop_type == "array": + if isinstance(value, list): + return value + else: + return [value] + elif prop_type == "object": + if isinstance(value, dict): + return self._clean_data(value, prop_schema) + else: + return {} + + return value + +# MCP tools with schema validation +validator = SchemaValidator() + +@mcp.tool() +def create_validated_user( + name: str, + email: str, + age: int, + preferences: Dict[str, Any] = None, + roles: List[str] = None +) -> Dict[str, Any]: + """Create user with schema validation.""" + import uuid + + user_data = { + "id": str(uuid.uuid4()), + "name": name, + "email": email, + "age": age, + "preferences": preferences or {"theme": "light", "notifications": True}, + "roles": roles or ["user"] + } + + # Validate against schema + validation_result = validator.validate_output(user_data, "user") + + if validation_result["valid"]: + return {"success": True, "user": validation_result["data"]} + else: + # Try cleaning + clean_result = validator.validate_and_clean(user_data, "user") + if clean_result["valid"]: + return { + "success": True, + "user": clean_result["data"], + "warning": "Data was cleaned during validation" + } + else: + return { + "success": False, + "error": clean_result["error"], + "path": clean_result.get("path", []) + } + +@mcp.tool() +def create_validated_product( + name: str, + price: float, + currency: str = "USD", + description: str = "", + category_name: str = "General", + tags: List[str] = None, + stock_quantity: int = 0 +) -> Dict[str, Any]: + """Create product with schema validation.""" + import uuid + from datetime import datetime + + product_data = { + "id": str(uuid.uuid4()), + "name": name, + "description": description, + "price": price, + "currency": currency.upper(), + "category": { + "id": str(uuid.uuid4()), + "name": category_name + }, + "tags": tags or [], + "in_stock": stock_quantity > 0, + "stock_quantity": stock_quantity, + "created_at": datetime.now().isoformat(), + "updated_at": datetime.now().isoformat() + } + + validation_result = validator.validate_and_clean(product_data, "product") + + if validation_result["valid"]: + return { + "success": True, + "product": validation_result["data"], + "cleaned": validation_result.get("cleaned", False) + } + else: + return { + "success": False, + "error": validation_result["error"], + "path": validation_result.get("path", []), + "details": "Product data failed schema validation" + } + +@mcp.tool() +def validate_data_against_schema( + data: Dict[str, Any], + schema_name: str +) -> Dict[str, Any]: + """Validate arbitrary data against a named schema.""" + if schema_name not in validator.schemas: + return { + "valid": False, + "error": f"Unknown schema: {schema_name}", + "available_schemas": list(validator.schemas.keys()) + } + + result = validator.validate_and_clean(data, schema_name) + + return { + "schema_name": schema_name, + "validation_result": result, + "schema": validator.schemas[schema_name] + } + +if __name__ == "__main__": + mcp.run() +``` + +## Performance optimization + +### Efficient serialization + +```python +""" +Optimized structured output with efficient serialization. +""" + +import json +import pickle +import time +from typing import Any, Dict, List, Optional, Protocol +from dataclasses import dataclass, asdict +from enum import Enum +import orjson # Fast JSON library + +class SerializationFormat(str, Enum): + """Supported serialization formats.""" + JSON = "json" + ORJSON = "orjson" + PICKLE = "pickle" + MSGPACK = "msgpack" + +class Serializer(Protocol): + """Serializer protocol.""" + + def serialize(self, data: Any) -> bytes: + """Serialize data to bytes.""" + ... + + def deserialize(self, data: bytes) -> Any: + """Deserialize bytes to data.""" + ... + +class JsonSerializer: + """Standard JSON serializer.""" + + def serialize(self, data: Any) -> bytes: + return json.dumps(data, default=str).encode('utf-8') + + def deserialize(self, data: bytes) -> Any: + return json.loads(data.decode('utf-8')) + +class OrjsonSerializer: + """Fast orjson serializer.""" + + def serialize(self, data: Any) -> bytes: + return orjson.dumps(data, default=str) + + def deserialize(self, data: bytes) -> Any: + return orjson.loads(data) + +class PickleSerializer: + """Pickle serializer (Python objects only).""" + + def serialize(self, data: Any) -> bytes: + return pickle.dumps(data) + + def deserialize(self, data: bytes) -> Any: + return pickle.loads(data) + +try: + import msgpack + + class MsgPackSerializer: + """MessagePack serializer.""" + + def serialize(self, data: Any) -> bytes: + return msgpack.packb(data, default=str) + + def deserialize(self, data: bytes) -> Any: + return msgpack.unpackb(data, raw=False) + + _MSGPACK_AVAILABLE = True +except ImportError: + _MSGPACK_AVAILABLE = False + +@dataclass +class PerformanceMetrics: + """Performance metrics for serialization.""" + format: str + serialization_time: float + deserialization_time: float + serialized_size: int + data_size_estimate: int + +class OptimizedStructuredOutput: + """Optimized structured output handler.""" + + def __init__(self, preferred_format: SerializationFormat = SerializationFormat.ORJSON): + self.preferred_format = preferred_format + self.serializers = { + SerializationFormat.JSON: JsonSerializer(), + SerializationFormat.ORJSON: OrjsonSerializer(), + SerializationFormat.PICKLE: PickleSerializer(), + } + + if _MSGPACK_AVAILABLE: + self.serializers[SerializationFormat.MSGPACK] = MsgPackSerializer() + + self.cache: Dict[str, tuple] = {} # Cache for expensive computations + self.performance_data: List[PerformanceMetrics] = [] + + def format_output( + self, + data: Any, + format_type: Optional[SerializationFormat] = None, + compress: bool = False + ) -> Dict[str, Any]: + """Format output with performance optimization.""" + format_type = format_type or self.preferred_format + + if format_type not in self.serializers: + raise ValueError(f"Unsupported format: {format_type}") + + # Check cache + cache_key = f"{format_type}:{hash(str(data))}" + if cache_key in self.cache: + cached_result, timestamp = self.cache[cache_key] + if time.time() - timestamp < 300: # 5 minute cache + return cached_result + + serializer = self.serializers[format_type] + + # Measure serialization performance + start_time = time.time() + serialized_data = serializer.serialize(data) + serialization_time = time.time() - start_time + + # Measure deserialization performance + start_time = time.time() + deserialized_data = serializer.deserialize(serialized_data) + deserialization_time = time.time() - start_time + + # Optional compression + if compress: + import gzip + compressed_data = gzip.compress(serialized_data) + compression_ratio = len(compressed_data) / len(serialized_data) + else: + compressed_data = serialized_data + compression_ratio = 1.0 + + # Record performance metrics + metrics = PerformanceMetrics( + format=format_type.value, + serialization_time=serialization_time, + deserialization_time=deserialization_time, + serialized_size=len(serialized_data), + data_size_estimate=len(str(data)) + ) + self.performance_data.append(metrics) + + result = { + "data": data, + "format": format_type.value, + "serialized_size": len(serialized_data), + "compressed_size": len(compressed_data), + "compression_ratio": compression_ratio, + "performance": { + "serialization_time_ms": serialization_time * 1000, + "deserialization_time_ms": deserialization_time * 1000, + "total_time_ms": (serialization_time + deserialization_time) * 1000 + } + } + + # Cache result + self.cache[cache_key] = (result, time.time()) + + return result + + def benchmark_formats(self, test_data: Any) -> Dict[str, PerformanceMetrics]: + """Benchmark different serialization formats.""" + results = {} + + for format_type in self.serializers: + try: + start_time = time.time() + formatted = self.format_output(test_data, format_type) + total_time = time.time() - start_time + + results[format_type.value] = { + "serialization_time_ms": formatted["performance"]["serialization_time_ms"], + "deserialization_time_ms": formatted["performance"]["deserialization_time_ms"], + "total_time_ms": formatted["performance"]["total_time_ms"], + "serialized_size": formatted["serialized_size"], + "efficiency_score": formatted["serialized_size"] / (total_time * 1000) # Size per ms + } + except Exception as e: + results[format_type.value] = {"error": str(e)} + + return results + + def get_performance_summary(self) -> Dict[str, Any]: + """Get performance summary across all operations.""" + if not self.performance_data: + return {"message": "No performance data available"} + + by_format = {} + for metrics in self.performance_data: + if metrics.format not in by_format: + by_format[metrics.format] = [] + by_format[metrics.format].append(metrics) + + summary = {} + for format_name, format_metrics in by_format.items(): + summary[format_name] = { + "operations_count": len(format_metrics), + "avg_serialization_time_ms": sum(m.serialization_time for m in format_metrics) / len(format_metrics) * 1000, + "avg_deserialization_time_ms": sum(m.deserialization_time for m in format_metrics) / len(format_metrics) * 1000, + "avg_serialized_size": sum(m.serialized_size for m in format_metrics) / len(format_metrics), + "total_data_processed": sum(m.data_size_estimate for m in format_metrics) + } + + return summary + +# Global optimizer instance +output_optimizer = OptimizedStructuredOutput() + +# Optimized tools +@mcp.tool() +def create_large_dataset( + size: int = 1000, + format_type: str = "orjson", + compress: bool = False +) -> Dict[str, Any]: + """Create large dataset with optimized output formatting.""" + import uuid + from datetime import datetime, timedelta + import random + + # Generate large dataset + dataset = [] + base_date = datetime.now() + + for i in range(size): + record = { + "id": str(uuid.uuid4()), + "name": f"Record {i}", + "value": random.uniform(0, 1000), + "category": random.choice(["A", "B", "C", "D"]), + "timestamp": (base_date + timedelta(minutes=i)).isoformat(), + "metadata": { + "source": f"source_{i % 10}", + "tags": [f"tag_{j}" for j in range(random.randint(1, 5))], + "properties": { + "x": random.uniform(-100, 100), + "y": random.uniform(-100, 100), + "z": random.uniform(-100, 100) + } + } + } + dataset.append(record) + + # Format with optimization + format_enum = SerializationFormat(format_type) + result = output_optimizer.format_output( + {"dataset": dataset, "size": size, "generated_at": datetime.now().isoformat()}, + format_enum, + compress + ) + + return result + +@mcp.tool() +def benchmark_serialization_formats( + data_size: int = 100 +) -> Dict[str, Any]: + """Benchmark different serialization formats.""" + # Create test data + test_data = { + "items": [ + { + "id": i, + "name": f"Item {i}", + "value": i * 1.5, + "active": i % 2 == 0, + "tags": [f"tag_{j}" for j in range(i % 5 + 1)] + } + for i in range(data_size) + ], + "metadata": { + "created_at": "2024-01-01T00:00:00Z", + "version": "1.0.0", + "config": { + "setting1": True, + "setting2": 42, + "setting3": "value" + } + } + } + + # Run benchmark + benchmark_results = output_optimizer.benchmark_formats(test_data) + + # Get recommendations + fastest_serialization = min( + benchmark_results.items(), + key=lambda x: x[1].get("serialization_time_ms", float('inf')) if isinstance(x[1], dict) else float('inf') + ) + + smallest_size = min( + benchmark_results.items(), + key=lambda x: x[1].get("serialized_size", float('inf')) if isinstance(x[1], dict) else float('inf') + ) + + return { + "test_data_size": data_size, + "benchmark_results": benchmark_results, + "recommendations": { + "fastest_serialization": fastest_serialization[0], + "smallest_output": smallest_size[0], + "recommended_for_speed": fastest_serialization[0], + "recommended_for_size": smallest_size[0] + }, + "performance_summary": output_optimizer.get_performance_summary() + } + +@mcp.tool() +def get_optimization_stats() -> Dict[str, Any]: + """Get optimization and performance statistics.""" + return { + "cache_size": len(output_optimizer.cache), + "total_operations": len(output_optimizer.performance_data), + "performance_summary": output_optimizer.get_performance_summary(), + "available_formats": [fmt.value for fmt in SerializationFormat if fmt in output_optimizer.serializers], + "current_preferred_format": output_optimizer.preferred_format.value, + "msgpack_available": _MSGPACK_AVAILABLE + } + +if __name__ == "__main__": + mcp.run() +``` + +## Best practices + +### Design guidelines + +- **Schema first** - Define clear schemas before implementation +- **Validation layers** - Validate at multiple levels (input, processing, output) +- **Error handling** - Provide detailed validation error messages +- **Documentation** - Include schema documentation in tool descriptions +- **Versioning** - Plan for schema evolution and backward compatibility + +### Performance considerations + +- **Lazy validation** - Validate only when necessary +- **Efficient serialization** - Choose appropriate serialization formats +- **Caching** - Cache validated and serialized outputs +- **Streaming** - Use streaming for large datasets +- **Compression** - Compress large outputs when appropriate + +### Schema evolution + +- **Backward compatibility** - Ensure new schemas work with old data +- **Optional fields** - Use optional fields for new additions +- **Default values** - Provide sensible defaults for new fields +- **Deprecation** - Plan deprecation paths for old fields +- **Migration** - Provide data migration utilities + +## Next steps + +- **[Completions](completions.md)** - LLM integration with structured output +- **[Low-level server](low-level-server.md)** - Advanced server implementation +- **[Parsing results](parsing-results.md)** - Client-side result processing +- **[Authentication](authentication.md)** - Secure structured data exchange \ No newline at end of file diff --git a/docs/tools.md b/docs/tools.md new file mode 100644 index 000000000..05b596e5a --- /dev/null +++ b/docs/tools.md @@ -0,0 +1,671 @@ +# Tools + +Tools are functions that LLMs can call to perform actions and computations. Unlike resources, tools can have side effects and perform operations that change state. + +## What are tools? + +Tools enable LLMs to: + +- **Perform computations** - Mathematical operations, data processing +- **Interact with external systems** - APIs, databases, file systems +- **Execute actions** - Send emails, create files, update records +- **Process data** - Transform, validate, or analyze information + +## Basic tool creation + +### Simple tools + +```python +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("Calculator") + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers together.""" + return a + b + +@mcp.tool() +def multiply(a: int, b: int) -> int: + """Multiply two numbers together.""" + return a * b + +@mcp.tool() +def calculate_average(numbers: list[float]) -> float: + """Calculate the average of a list of numbers.""" + if not numbers: + raise ValueError("Cannot calculate average of empty list") + return sum(numbers) / len(numbers) +``` + +### Tools with default parameters + +```python +@mcp.tool() +def greet(name: str, greeting: str = "Hello", punctuation: str = "!") -> str: + """Greet someone with a customizable message.""" + return f"{greeting}, {name}{punctuation}" + +@mcp.tool() +def format_currency( + amount: float, + currency: str = "USD", + decimal_places: int = 2 +) -> str: + """Format a number as currency.""" + symbol_map = { + "USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥" + } + symbol = symbol_map.get(currency, currency) + return f"{symbol}{amount:.{decimal_places}f}" +``` + +## Structured output + +Tools can return structured data that's automatically validated and typed: + +### Using Pydantic models + +```python +from pydantic import BaseModel, Field +from typing import Optional + +class WeatherData(BaseModel): + """Weather information structure.""" + temperature: float = Field(description="Temperature in Celsius") + humidity: float = Field(description="Humidity percentage", ge=0, le=100) + condition: str = Field(description="Weather condition") + wind_speed: float = Field(description="Wind speed in km/h", ge=0) + location: str = Field(description="Location name") + +@mcp.tool() +def get_weather(city: str) -> WeatherData: + """Get weather data for a city - returns structured data.""" + # Simulate weather API call + return WeatherData( + temperature=22.5, + humidity=65.0, + condition="Partly cloudy", + wind_speed=12.3, + location=city + ) +``` + +### Using TypedDict + +```python +from typing import TypedDict + +class LocationInfo(TypedDict): + latitude: float + longitude: float + name: str + country: str + +@mcp.tool() +def get_location(address: str) -> LocationInfo: + """Get location coordinates for an address.""" + # Simulate geocoding API + return LocationInfo( + latitude=51.5074, + longitude=-0.1278, + name="London", + country="United Kingdom" + ) +``` + +### Using dataclasses + +```python +from dataclasses import dataclass +from typing import Optional + +@dataclass +class UserProfile: + """User profile information.""" + name: str + age: int + email: Optional[str] = None + verified: bool = False + +@mcp.tool() +def create_user_profile(name: str, age: int, email: Optional[str] = None) -> UserProfile: + """Create a new user profile.""" + return UserProfile( + name=name, + age=age, + email=email, + verified=email is not None + ) +``` + +### Simple structured data + +```python +@mcp.tool() +def analyze_text(text: str) -> dict[str, int]: + """Analyze text and return statistics.""" + words = text.split() + return { + "character_count": len(text), + "word_count": len(words), + "sentence_count": text.count('.') + text.count('!') + text.count('?'), + "paragraph_count": text.count('\\n\\n') + 1 + } + +@mcp.tool() +def get_prime_numbers(limit: int) -> list[int]: + """Get all prime numbers up to a limit.""" + if limit < 2: + return [] + + primes = [] + for num in range(2, limit + 1): + for i in range(2, int(num ** 0.5) + 1): + if num % i == 0: + break + else: + primes.append(num) + + return primes +``` + +## Advanced tool patterns + +### Tools with context + +Access request context, logging, and progress reporting: + +```python +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + +mcp = FastMCP("Advanced Tools") + +@mcp.tool() +async def long_running_task( + task_name: str, + steps: int, + ctx: Context[ServerSession, None] +) -> str: + """Execute a long-running task with progress updates.""" + await ctx.info(f"Starting task: {task_name}") + + for i in range(steps): + # Report progress + progress = (i + 1) / steps + await ctx.report_progress( + progress=progress, + total=1.0, + message=f"Step {i + 1}/{steps}: Processing..." + ) + + # Simulate work + await asyncio.sleep(0.1) + await ctx.debug(f"Completed step {i + 1}") + + await ctx.info(f"Task '{task_name}' completed successfully") + return f"Task '{task_name}' completed in {steps} steps" + +@mcp.tool() +async def read_and_process(resource_uri: str, ctx: Context) -> str: + """Read a resource and process its content.""" + try: + # Read a resource from within a tool + resource_content = await ctx.read_resource(resource_uri) + + # Process the content + content = resource_content.contents[0] + if hasattr(content, 'text'): + text = content.text + word_count = len(text.split()) + await ctx.info(f"Processed {word_count} words from {resource_uri}") + return f"Processed resource with {word_count} words" + else: + return "Resource content was not text" + + except Exception as e: + await ctx.error(f"Failed to process resource: {e}") + raise +``` + +### Database integration tools + +```python +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +class Database: + """Mock database class.""" + + @classmethod + async def connect(cls) -> "Database": + return cls() + + async def disconnect(self) -> None: + pass + + async def create_user(self, name: str, email: str) -> dict: + return {"id": "123", "name": name, "email": email, "created": "2024-01-01"} + + async def get_user(self, user_id: str) -> dict | None: + return {"id": user_id, "name": "John Doe", "email": "john@example.com"} + + async def update_user(self, user_id: str, **updates) -> dict: + return {"id": user_id, **updates, "updated": "2024-01-01"} + +@dataclass +class AppContext: + db: Database + +@asynccontextmanager +async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: + db = await Database.connect() + try: + yield AppContext(db=db) + finally: + await db.disconnect() + +mcp = FastMCP("Database Tools", lifespan=app_lifespan) + +@mcp.tool() +async def create_user( + name: str, + email: str, + ctx: Context[ServerSession, AppContext] +) -> dict: + """Create a new user in the database.""" + db = ctx.request_context.lifespan_context.db + + # Validate email format + if "@" not in email: + raise ValueError("Invalid email format") + + user = await db.create_user(name, email) + await ctx.info(f"Created user {name} with ID {user['id']}") + return user + +@mcp.tool() +async def get_user( + user_id: str, + ctx: Context[ServerSession, AppContext] +) -> dict: + """Retrieve user information by ID.""" + db = ctx.request_context.lifespan_context.db + + user = await db.get_user(user_id) + if not user: + raise ValueError(f"User with ID {user_id} not found") + + return user + +@mcp.tool() +async def update_user( + user_id: str, + name: Optional[str] = None, + email: Optional[str] = None, + ctx: Context[ServerSession, AppContext] +) -> dict: + """Update user information.""" + db = ctx.request_context.lifespan_context.db + + # Build updates dict + updates = {} + if name is not None: + updates["name"] = name + if email is not None: + if "@" not in email: + raise ValueError("Invalid email format") + updates["email"] = email + + if not updates: + raise ValueError("No updates provided") + + user = await db.update_user(user_id, **updates) + await ctx.info(f"Updated user {user_id}") + return user +``` + +### File system tools + +```python +import os +from pathlib import Path +from typing import List + +# Security: Define allowed directory +ALLOWED_DIR = Path("/safe/directory") + +@mcp.tool() +def create_file(filename: str, content: str) -> str: + """Create a new file with the given content.""" + # Security validation + if ".." in filename or "/" in filename: + raise ValueError("Invalid filename: path traversal not allowed") + + file_path = ALLOWED_DIR / filename + + # Check if file already exists + if file_path.exists(): + raise ValueError(f"File {filename} already exists") + + # Create file + file_path.write_text(content, encoding="utf-8") + return f"Created file {filename} ({len(content)} characters)" + +@mcp.tool() +def read_file(filename: str) -> str: + """Read the contents of a file.""" + if ".." in filename or "/" in filename: + raise ValueError("Invalid filename") + + file_path = ALLOWED_DIR / filename + + if not file_path.exists(): + raise ValueError(f"File {filename} not found") + + try: + content = file_path.read_text(encoding="utf-8") + return content + except UnicodeDecodeError: + raise ValueError(f"File {filename} is not a text file") + +@mcp.tool() +def list_files() -> List[str]: + """List all files in the allowed directory.""" + try: + files = [f.name for f in ALLOWED_DIR.iterdir() if f.is_file()] + return sorted(files) + except OSError as e: + raise ValueError(f"Cannot list files: {e}") + +@mcp.tool() +def delete_file(filename: str) -> str: + """Delete a file.""" + if ".." in filename or "/" in filename: + raise ValueError("Invalid filename") + + file_path = ALLOWED_DIR / filename + + if not file_path.exists(): + raise ValueError(f"File {filename} not found") + + file_path.unlink() + return f"Deleted file {filename}" +``` + +### API integration tools + +```python +import aiohttp +import json +from typing import Any + +@mcp.tool() +async def fetch_json(url: str, headers: Optional[dict[str, str]] = None) -> dict[str, Any]: + """Fetch JSON data from a URL.""" + # Security: validate URL + if not url.startswith(("http://", "https://")): + raise ValueError("URL must use HTTP or HTTPS") + + async with aiohttp.ClientSession() as session: + try: + async with session.get(url, headers=headers or {}) as response: + if response.status != 200: + raise ValueError(f"HTTP {response.status}: {response.reason}") + + data = await response.json() + return data + + except aiohttp.ClientError as e: + raise ValueError(f"Request failed: {e}") + +@mcp.tool() +async def send_webhook( + url: str, + data: dict[str, Any], + method: str = "POST" +) -> dict[str, Any]: + """Send a webhook with JSON data.""" + if not url.startswith(("http://", "https://")): + raise ValueError("URL must use HTTP or HTTPS") + + if method not in ["POST", "PUT", "PATCH"]: + raise ValueError("Method must be POST, PUT, or PATCH") + + async with aiohttp.ClientSession() as session: + try: + async with session.request( + method, + url, + json=data, + headers={"Content-Type": "application/json"} + ) as response: + response_data = { + "status": response.status, + "headers": dict(response.headers), + } + + if response.headers.get("content-type", "").startswith("application/json"): + response_data["data"] = await response.json() + else: + response_data["text"] = await response.text() + + return response_data + + except aiohttp.ClientError as e: + raise ValueError(f"Webhook failed: {e}") +``` + +## Error handling and validation + +### Input validation with Pydantic + +```python +from pydantic import Field, validator +from typing import Annotated + +@mcp.tool() +def validate_email( + email: Annotated[str, Field(description="Email address to validate")] +) -> dict[str, bool]: + """Validate an email address format.""" + import re + + pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$' + is_valid = bool(re.match(pattern, email)) + + return { + "email": email, + "is_valid": is_valid, + "has_at_symbol": "@" in email, + "has_domain": "." in email.split("@")[-1] if "@" in email else False + } + +@mcp.tool() +def process_age( + age: Annotated[int, Field(ge=0, le=150, description="Person's age in years")] +) -> str: + """Process a person's age with automatic validation.""" + if age < 18: + return f"Minor: {age} years old" + elif age < 65: + return f"Adult: {age} years old" + else: + return f"Senior: {age} years old" +``` + +### Custom error handling + +```python +class CalculationError(Exception): + """Custom exception for calculation errors.""" + pass + +@mcp.tool() +def safe_divide(a: float, b: float) -> dict[str, Any]: + """Divide two numbers with comprehensive error handling.""" + try: + if b == 0: + raise CalculationError("Division by zero is not allowed") + + result = a / b + + return { + "dividend": a, + "divisor": b, + "quotient": result, + "success": True + } + + except CalculationError as e: + return { + "dividend": a, + "divisor": b, + "error": str(e), + "success": False + } + +@mcp.tool() +async def robust_api_call(endpoint: str, ctx: Context) -> dict[str, Any]: + """Make an API call with comprehensive error handling.""" + try: + await ctx.info(f"Calling API endpoint: {endpoint}") + + # Simulate API call + if "error" in endpoint: + raise ValueError("Simulated API error") + + return {"status": "success", "data": "API response"} + + except ValueError as e: + await ctx.error(f"API call failed: {e}") + return {"status": "error", "message": str(e)} + except Exception as e: + await ctx.error(f"Unexpected error: {e}") + return {"status": "error", "message": "Internal server error"} +``` + +## Testing tools + +### Unit testing + +```python +import pytest +from mcp.server.fastmcp import FastMCP + +def test_basic_tool(): + mcp = FastMCP("Test") + + @mcp.tool() + def add(a: int, b: int) -> int: + return a + b + + result = add(2, 3) + assert result == 5 + +def test_tool_with_validation(): + mcp = FastMCP("Test") + + @mcp.tool() + def divide(a: float, b: float) -> float: + if b == 0: + raise ValueError("Cannot divide by zero") + return a / b + + assert divide(10, 2) == 5.0 + + with pytest.raises(ValueError, match="Cannot divide by zero"): + divide(10, 0) + +@pytest.mark.asyncio +async def test_async_tool(): + mcp = FastMCP("Test") + + @mcp.tool() + async def async_add(a: int, b: int) -> int: + return a + b + + result = await async_add(3, 4) + assert result == 7 +``` + +### Integration testing + +```python +import asyncio +from unittest.mock import AsyncMock + +@pytest.mark.asyncio +async def test_database_tool(): + # Mock database + mock_db = AsyncMock() + mock_db.create_user.return_value = {"id": "123", "name": "Test User"} + + # Test the tool function directly + mcp = FastMCP("Test") + + @mcp.tool() + async def create_user_tool(name: str, email: str) -> dict: + user = await mock_db.create_user(name, email) + return user + + result = await create_user_tool("Test User", "test@example.com") + assert result["name"] == "Test User" + mock_db.create_user.assert_called_once_with("Test User", "test@example.com") +``` + +## Best practices + +### Design principles + +- **Single responsibility** - Each tool should do one thing well +- **Clear naming** - Use descriptive function and parameter names +- **Comprehensive docstrings** - Explain what the tool does and its parameters +- **Input validation** - Validate all parameters thoroughly +- **Error handling** - Provide clear, actionable error messages + +### Performance considerations + +- **Use async/await** for I/O operations +- **Implement timeouts** for external API calls +- **Cache expensive computations** where appropriate +- **Batch operations** when possible + +### Security guidelines + +- **Validate all inputs** - Never trust user input +- **Sanitize file paths** - Prevent directory traversal attacks +- **Limit resource access** - Use allow-lists for files and URLs +- **Handle authentication** - Verify permissions for sensitive operations +- **Log security events** - Track access and errors + +## Common use cases + +### Mathematical tools +- Calculations and formulas +- Statistical analysis +- Data transformations + +### Data processing tools +- Text analysis and manipulation +- File operations +- Format conversions + +### Integration tools +- API calls and webhooks +- Database operations +- External service interactions + +### Utility tools +- Validation and formatting +- System information +- Configuration management + +## Next steps + +- **[Working with context](context.md)** - Access request context and capabilities +- **[Structured output patterns](structured-output.md)** - Advanced typing techniques +- **[Server deployment](running-servers.md)** - Deploy tools in production +- **[Authentication](authentication.md)** - Secure tool access \ No newline at end of file diff --git a/docs/writing-clients.md b/docs/writing-clients.md new file mode 100644 index 000000000..f0e060883 --- /dev/null +++ b/docs/writing-clients.md @@ -0,0 +1,1078 @@ +# Writing clients + +Learn how to build MCP clients that can connect to servers using various transports and handle the full MCP protocol. + +## Overview + +MCP clients enable applications to: + +- **Connect to MCP servers** using stdio, SSE, or Streamable HTTP transports +- **Discover capabilities** - List available tools, resources, and prompts +- **Execute operations** - Call tools, read resources, and get prompts +- **Handle real-time updates** - Receive notifications and progress updates + +## Basic client setup + +### stdio client + +The simplest way to connect to MCP servers: + +```python +""" +Basic stdio client example. +""" + +import asyncio +import os +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +async def basic_stdio_client(): + """Connect to an MCP server via stdio.""" + + # Configure server parameters + server_params = StdioServerParameters( + command="uv", + args=["run", "server", "quickstart", "stdio"], + env={"UV_INDEX": os.environ.get("UV_INDEX", "")} + ) + + # Connect to server + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + # Initialize the connection + init_result = await session.initialize() + print(f"Connected to: {init_result.serverInfo.name}") + print(f"Protocol version: {init_result.protocolVersion}") + + # List available tools + tools = await session.list_tools() + print(f"Available tools: {[tool.name for tool in tools.tools]}") + + # Call a tool + if tools.tools: + tool_name = tools.tools[0].name + result = await session.call_tool(tool_name, {"a": 5, "b": 3}) + + # Handle result + if result.content: + content = result.content[0] + if hasattr(content, 'text'): + print(f"Tool result: {content.text}") + +if __name__ == "__main__": + asyncio.run(basic_stdio_client()) +``` + +### HTTP client + +Connect to servers using HTTP transports: + +```python +""" +HTTP client using Streamable HTTP transport. +""" + +import asyncio +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + +async def http_client_example(): + """Connect to MCP server via HTTP.""" + + server_url = "http://localhost:8000/mcp" + + async with streamablehttp_client(server_url) as (read, write, session_info): + async with ClientSession(read, write) as session: + # Initialize connection + await session.initialize() + + # Get server capabilities + print(f"Server capabilities: {session.server_capabilities}") + + # List resources + resources = await session.list_resources() + print(f"Available resources: {[r.uri for r in resources.resources]}") + + # Read a resource + if resources.resources: + resource_uri = resources.resources[0].uri + content = await session.read_resource(resource_uri) + + for item in content.contents: + if hasattr(item, 'text'): + print(f"Resource content: {item.text[:100]}...") + +if __name__ == "__main__": + asyncio.run(http_client_example()) +``` + +## Advanced client patterns + +### Error handling and retries + +```python +""" +Robust client with error handling and retries. +""" + +import asyncio +import logging +from typing import Any +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client +from mcp.shared.exceptions import McpError + +logger = logging.getLogger(__name__) + +class RobustMcpClient: + """MCP client with robust error handling.""" + + def __init__(self, server_params: StdioServerParameters, max_retries: int = 3): + self.server_params = server_params + self.max_retries = max_retries + self.session: ClientSession | None = None + + async def connect(self) -> bool: + """Connect to the server with retries.""" + for attempt in range(self.max_retries): + try: + logger.info(f"Connection attempt {attempt + 1}/{self.max_retries}") + + self.read_stream, self.write_stream = await stdio_client( + self.server_params + ).__aenter__() + + self.session = ClientSession(self.read_stream, self.write_stream) + await self.session.__aenter__() + await self.session.initialize() + + logger.info("Successfully connected to MCP server") + return True + + except Exception as e: + logger.warning(f"Connection attempt {attempt + 1} failed: {e}") + if attempt == self.max_retries - 1: + logger.error("All connection attempts failed") + return False + + await asyncio.sleep(2 ** attempt) # Exponential backoff + + return False + + async def call_tool_safely(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]: + """Call tool with error handling.""" + if not self.session: + raise RuntimeError("Not connected to server") + + try: + result = await self.session.call_tool(name, arguments) + + if result.isError: + return { + "success": False, + "error": "Tool execution failed", + "content": [item.text if hasattr(item, 'text') else str(item) + for item in result.content] + } + + # Extract content + content_items = [] + for item in result.content: + if hasattr(item, 'text'): + content_items.append(item.text) + elif hasattr(item, 'data'): + content_items.append(f"") + else: + content_items.append(str(item)) + + return { + "success": True, + "content": content_items, + "structured": result.structuredContent if hasattr(result, 'structuredContent') else None + } + + except McpError as e: + logger.error(f"MCP error calling tool {name}: {e}") + return {"success": False, "error": f"MCP error: {e}"} + + except Exception as e: + logger.error(f"Unexpected error calling tool {name}: {e}") + return {"success": False, "error": f"Unexpected error: {e}"} + + async def read_resource_safely(self, uri: str) -> dict[str, Any]: + """Read resource with error handling.""" + if not self.session: + raise RuntimeError("Not connected to server") + + try: + result = await self.session.read_resource(uri) + + content_items = [] + for item in result.contents: + if hasattr(item, 'text'): + content_items.append({"type": "text", "content": item.text}) + elif hasattr(item, 'data'): + content_items.append({ + "type": "binary", + "size": len(item.data), + "mime_type": getattr(item, 'mimeType', 'application/octet-stream') + }) + else: + content_items.append({"type": "unknown", "content": str(item)}) + + return {"success": True, "contents": content_items} + + except Exception as e: + logger.error(f"Error reading resource {uri}: {e}") + return {"success": False, "error": str(e)} + + async def disconnect(self): + """Clean disconnect from server.""" + if self.session: + try: + await self.session.__aexit__(None, None, None) + except: + pass + self.session = None + +# Usage example +async def robust_client_example(): + """Example using the robust client.""" + server_params = StdioServerParameters( + command="python", + args=["my_server.py"] + ) + + client = RobustMcpClient(server_params) + + if await client.connect(): + # Use the client + result = await client.call_tool_safely("add", {"a": 10, "b": 20}) + print(f"Tool result: {result}") + + resource_result = await client.read_resource_safely("config://settings") + print(f"Resource result: {resource_result}") + + await client.disconnect() + else: + print("Failed to connect to server") + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + asyncio.run(robust_client_example()) +``` + +### Interactive client + +```python +""" +Interactive MCP client with command-line interface. +""" + +import asyncio +import cmd +import json +from typing import Any +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +class InteractiveMcpClient(cmd.Cmd): + """Interactive command-line MCP client.""" + + intro = "Welcome to the MCP Interactive Client. Type help or ? for commands." + prompt = "(mcp) " + + def __init__(self): + super().__init__() + self.session: ClientSession | None = None + self.connected = False + self.tools = [] + self.resources = [] + self.prompts = [] + + def do_connect(self, args: str): + """Connect to MCP server: connect [args...]""" + if not args: + print("Usage: connect [args...]") + return + + parts = args.split() + command = parts[0] + server_args = parts[1:] if len(parts) > 1 else [] + + asyncio.run(self._connect(command, server_args)) + + async def _connect(self, command: str, args: list[str]): + """Async connect implementation.""" + try: + server_params = StdioServerParameters(command=command, args=args) + + self.read_stream, self.write_stream = await stdio_client( + server_params + ).__aenter__() + + self.session = ClientSession(self.read_stream, self.write_stream) + await self.session.__aenter__() + + init_result = await self.session.initialize() + + print(f"Connected to: {init_result.serverInfo.name}") + print(f"Version: {init_result.serverInfo.version}") + + self.connected = True + await self._refresh_capabilities() + + except Exception as e: + print(f"Connection failed: {e}") + + async def _refresh_capabilities(self): + """Refresh server capabilities.""" + if not self.session: + return + + try: + # List tools + tools_response = await self.session.list_tools() + self.tools = tools_response.tools + + # List resources + resources_response = await self.session.list_resources() + self.resources = resources_response.resources + + # List prompts + prompts_response = await self.session.list_prompts() + self.prompts = prompts_response.prompts + + print(f"Discovered: {len(self.tools)} tools, {len(self.resources)} resources, {len(self.prompts)} prompts") + + except Exception as e: + print(f"Error refreshing capabilities: {e}") + + def do_list(self, args: str): + """List available tools, resources, or prompts: list [tools|resources|prompts]""" + if not self.connected: + print("Not connected to server") + return + + if not args or args == "tools": + print("Available tools:") + for tool in self.tools: + print(f" {tool.name}: {tool.description}") + + elif args == "resources": + print("Available resources:") + for resource in self.resources: + print(f" {resource.uri}: {resource.name}") + + elif args == "prompts": + print("Available prompts:") + for prompt in self.prompts: + print(f" {prompt.name}: {prompt.description}") + + else: + print("Usage: list [tools|resources|prompts]") + + def do_call(self, args: str): + """Call a tool: call """ + if not self.connected: + print("Not connected to server") + return + + parts = args.split(maxsplit=1) + if len(parts) != 2: + print("Usage: call ") + return + + tool_name, json_args = parts + + try: + arguments = json.loads(json_args) + asyncio.run(self._call_tool(tool_name, arguments)) + except json.JSONDecodeError: + print("Invalid JSON arguments") + + async def _call_tool(self, name: str, arguments: dict[str, Any]): + """Async tool call implementation.""" + try: + result = await self.session.call_tool(name, arguments) + + if result.isError: + print("Tool execution failed:") + for content in result.content: + if hasattr(content, 'text'): + print(f" {content.text}") + else: + print("Tool result:") + for content in result.content: + if hasattr(content, 'text'): + print(f" {content.text}") + + # Show structured content if available + if hasattr(result, 'structuredContent') and result.structuredContent: + print("Structured result:") + print(f" {json.dumps(result.structuredContent, indent=2)}") + + except Exception as e: + print(f"Error calling tool: {e}") + + def do_read(self, args: str): + """Read a resource: read """ + if not self.connected: + print("Not connected to server") + return + + if not args: + print("Usage: read ") + return + + asyncio.run(self._read_resource(args)) + + async def _read_resource(self, uri: str): + """Async resource read implementation.""" + try: + result = await self.session.read_resource(uri) + + print(f"Resource content for {uri}:") + for content in result.contents: + if hasattr(content, 'text'): + print(content.text) + elif hasattr(content, 'data'): + print(f"") + + except Exception as e: + print(f"Error reading resource: {e}") + + def do_prompt(self, args: str): + """Get a prompt: prompt """ + if not self.connected: + print("Not connected to server") + return + + parts = args.split(maxsplit=1) + if len(parts) < 1: + print("Usage: prompt [json_arguments]") + return + + prompt_name = parts[0] + arguments = {} + + if len(parts) == 2: + try: + arguments = json.loads(parts[1]) + except json.JSONDecodeError: + print("Invalid JSON arguments") + return + + asyncio.run(self._get_prompt(prompt_name, arguments)) + + async def _get_prompt(self, name: str, arguments: dict[str, Any]): + """Async prompt get implementation.""" + try: + result = await self.session.get_prompt(name, arguments) + + print(f"Prompt: {result.description}") + for message in result.messages: + print(f" {message.role}: {message.content.text}") + + except Exception as e: + print(f"Error getting prompt: {e}") + + def do_disconnect(self, args: str): + """Disconnect from server""" + if self.connected: + asyncio.run(self._disconnect()) + print("Disconnected") + else: + print("Not connected") + + async def _disconnect(self): + """Async disconnect implementation.""" + if self.session: + await self.session.__aexit__(None, None, None) + self.session = None + self.connected = False + + def do_quit(self, args: str): + """Quit the client""" + if self.connected: + asyncio.run(self._disconnect()) + return True + + def do_EOF(self, args: str): + """Handle Ctrl+D""" + print() + return self.do_quit(args) + +# Run the interactive client +if __name__ == "__main__": + InteractiveMcpClient().cmdloop() +``` + +## Client-side caching + +### Smart caching client + +```python +""" +MCP client with intelligent caching. +""" + +import asyncio +import hashlib +import time +from typing import Any, Dict, Optional +from dataclasses import dataclass +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +@dataclass +class CacheEntry: + """Cache entry with TTL support.""" + data: Any + timestamp: float + ttl: float + +class CachingMcpClient: + """MCP client with caching capabilities.""" + + def __init__(self, server_params: StdioServerParameters, default_ttl: float = 300): + self.server_params = server_params + self.default_ttl = default_ttl + self.cache: Dict[str, CacheEntry] = {} + self.session: Optional[ClientSession] = None + + def _cache_key(self, operation: str, **kwargs) -> str: + """Generate cache key from operation and parameters.""" + key_data = f"{operation}:{kwargs}" + return hashlib.md5(key_data.encode()).hexdigest() + + def _is_cache_valid(self, entry: CacheEntry) -> bool: + """Check if cache entry is still valid.""" + return time.time() - entry.timestamp < entry.ttl + + def _get_cached(self, key: str) -> Optional[Any]: + """Get cached value if valid.""" + if key in self.cache: + entry = self.cache[key] + if self._is_cache_valid(entry): + return entry.data + else: + del self.cache[key] + return None + + def _set_cached(self, key: str, data: Any, ttl: Optional[float] = None): + """Cache data with TTL.""" + if ttl is None: + ttl = self.default_ttl + + self.cache[key] = CacheEntry( + data=data, + timestamp=time.time(), + ttl=ttl + ) + + async def connect(self): + """Connect to the MCP server.""" + self.read_stream, self.write_stream = await stdio_client( + self.server_params + ).__aenter__() + + self.session = ClientSession(self.read_stream, self.write_stream) + await self.session.__aenter__() + await self.session.initialize() + + async def list_tools_cached(self, ttl: float = 600) -> list: + """List tools with caching (tools change infrequently).""" + cache_key = self._cache_key("list_tools") + cached = self._get_cached(cache_key) + + if cached is not None: + return cached + + if not self.session: + raise RuntimeError("Not connected") + + result = await self.session.list_tools() + tools = [ + { + "name": tool.name, + "description": tool.description, + "input_schema": tool.inputSchema + } + for tool in result.tools + ] + + self._set_cached(cache_key, tools, ttl) + return tools + + async def call_tool_cached( + self, + name: str, + arguments: Dict[str, Any], + ttl: Optional[float] = None, + force_refresh: bool = False + ) -> Dict[str, Any]: + """Call tool with optional caching.""" + cache_key = self._cache_key("call_tool", name=name, arguments=arguments) + + if not force_refresh: + cached = self._get_cached(cache_key) + if cached is not None: + return {"cached": True, **cached} + + if not self.session: + raise RuntimeError("Not connected") + + result = await self.session.call_tool(name, arguments) + + # Process result + processed_result = { + "success": not result.isError, + "content": [ + item.text if hasattr(item, 'text') else str(item) + for item in result.content + ] + } + + if hasattr(result, 'structuredContent') and result.structuredContent: + processed_result["structured"] = result.structuredContent + + # Cache successful results if TTL specified + if ttl is not None and processed_result["success"]: + self._set_cached(cache_key, processed_result, ttl) + + return {"cached": False, **processed_result} + + async def read_resource_cached( + self, + uri: str, + ttl: float = 60, + force_refresh: bool = False + ) -> Dict[str, Any]: + """Read resource with caching.""" + cache_key = self._cache_key("read_resource", uri=uri) + + if not force_refresh: + cached = self._get_cached(cache_key) + if cached is not None: + return {"cached": True, **cached} + + if not self.session: + raise RuntimeError("Not connected") + + result = await self.session.read_resource(uri) + + processed_result = { + "uri": uri, + "contents": [ + { + "type": "text" if hasattr(item, 'text') else "binary", + "content": item.text if hasattr(item, 'text') else f"<{len(item.data)} bytes>" + } + for item in result.contents + ] + } + + self._set_cached(cache_key, processed_result, ttl) + return {"cached": False, **processed_result} + + def clear_cache(self, pattern: Optional[str] = None): + """Clear cache entries matching pattern.""" + if pattern is None: + self.cache.clear() + else: + keys_to_remove = [k for k in self.cache.keys() if pattern in k] + for key in keys_to_remove: + del self.cache[key] + + def cache_stats(self) -> Dict[str, Any]: + """Get cache statistics.""" + now = time.time() + valid_entries = sum( + 1 for entry in self.cache.values() + if now - entry.timestamp < entry.ttl + ) + + return { + "total_entries": len(self.cache), + "valid_entries": valid_entries, + "expired_entries": len(self.cache) - valid_entries, + "cache_hit_potential": valid_entries / len(self.cache) if self.cache else 0 + } + +# Usage example +async def caching_client_example(): + """Example using caching client.""" + server_params = StdioServerParameters( + command="python", + args=["server.py"] + ) + + client = CachingMcpClient(server_params, default_ttl=120) + await client.connect() + + # First call - will hit server + result1 = await client.call_tool_cached("add", {"a": 5, "b": 3}, ttl=60) + print(f"First call (cached: {result1['cached']}): {result1['content']}") + + # Second call - will use cache + result2 = await client.call_tool_cached("add", {"a": 5, "b": 3}) + print(f"Second call (cached: {result2['cached']}): {result2['content']}") + + # Resource with caching + resource1 = await client.read_resource_cached("config://settings", ttl=30) + print(f"Resource (cached: {resource1['cached']})") + + # Cache stats + stats = client.cache_stats() + print(f"Cache stats: {stats}") + +if __name__ == "__main__": + asyncio.run(caching_client_example()) +``` + +## Production client patterns + +### Connection pooling client + +```python +""" +Production MCP client with connection pooling. +""" + +import asyncio +import logging +from typing import Any, Dict, List, Optional +from contextlib import asynccontextmanager +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +logger = logging.getLogger(__name__) + +class ConnectionPool: + """Connection pool for MCP clients.""" + + def __init__( + self, + server_params: StdioServerParameters, + pool_size: int = 5, + max_retries: int = 3 + ): + self.server_params = server_params + self.pool_size = pool_size + self.max_retries = max_retries + self.available_connections: asyncio.Queue = asyncio.Queue() + self.active_connections: set = set() + self.closed = False + + async def initialize(self): + """Initialize the connection pool.""" + for _ in range(self.pool_size): + connection = await self._create_connection() + if connection: + await self.available_connections.put(connection) + + async def _create_connection(self) -> Optional[ClientSession]: + """Create a new connection with retries.""" + for attempt in range(self.max_retries): + try: + read_stream, write_stream = await stdio_client( + self.server_params + ).__aenter__() + + session = ClientSession(read_stream, write_stream) + await session.__aenter__() + await session.initialize() + + logger.info("Created new MCP connection") + return session + + except Exception as e: + logger.warning(f"Connection attempt {attempt + 1} failed: {e}") + if attempt < self.max_retries - 1: + await asyncio.sleep(2 ** attempt) + + logger.error("Failed to create connection after all retries") + return None + + @asynccontextmanager + async def get_connection(self): + """Get a connection from the pool.""" + if self.closed: + raise RuntimeError("Connection pool is closed") + + try: + # Try to get an available connection + connection = await asyncio.wait_for( + self.available_connections.get(), + timeout=10.0 + ) + + self.active_connections.add(connection) + yield connection + + except asyncio.TimeoutError: + logger.error("Timeout waiting for available connection") + raise + + finally: + # Return connection to pool + if connection in self.active_connections: + self.active_connections.remove(connection) + await self.available_connections.put(connection) + + async def close(self): + """Close all connections in the pool.""" + self.closed = True + + # Close active connections + for connection in list(self.active_connections): + try: + await connection.__aexit__(None, None, None) + except: + pass + + # Close available connections + while not self.available_connections.empty(): + try: + connection = self.available_connections.get_nowait() + await connection.__aexit__(None, None, None) + except: + pass + + logger.info("Connection pool closed") + +class PooledMcpClient: + """MCP client using connection pooling.""" + + def __init__(self, connection_pool: ConnectionPool): + self.pool = connection_pool + + async def call_tool(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: + """Call tool using pooled connection.""" + async with self.pool.get_connection() as session: + result = await session.call_tool(name, arguments) + + return { + "success": not result.isError, + "content": [ + item.text if hasattr(item, 'text') else str(item) + for item in result.content + ], + "structured": getattr(result, 'structuredContent', None) + } + + async def read_resource(self, uri: str) -> Dict[str, Any]: + """Read resource using pooled connection.""" + async with self.pool.get_connection() as session: + result = await session.read_resource(uri) + + return { + "uri": uri, + "contents": [ + item.text if hasattr(item, 'text') else f"" + for item in result.contents + ] + } + + async def list_capabilities(self) -> Dict[str, List[str]]: + """List server capabilities using pooled connection.""" + async with self.pool.get_connection() as session: + tools = await session.list_tools() + resources = await session.list_resources() + prompts = await session.list_prompts() + + return { + "tools": [tool.name for tool in tools.tools], + "resources": [resource.uri for resource in resources.resources], + "prompts": [prompt.name for prompt in prompts.prompts] + } + +# Usage example +async def pooled_client_example(): + """Example using connection pool.""" + server_params = StdioServerParameters( + command="python", + args=["server.py"] + ) + + # Create and initialize connection pool + pool = ConnectionPool(server_params, pool_size=3) + await pool.initialize() + + client = PooledMcpClient(pool) + + try: + # Concurrent operations using pool + tasks = [ + client.call_tool("add", {"a": i, "b": i*2}) + for i in range(10) + ] + + results = await asyncio.gather(*tasks) + + for i, result in enumerate(results): + print(f"Task {i}: {result}") + + # List capabilities + capabilities = await client.list_capabilities() + print(f"Server capabilities: {capabilities}") + + finally: + await pool.close() + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + asyncio.run(pooled_client_example()) +``` + +## Testing client implementations + +### Client testing framework + +```python +""" +Testing framework for MCP clients. +""" + +import pytest +import asyncio +from unittest.mock import Mock, AsyncMock +from mcp import ClientSession +from mcp.types import Tool, InitializeResult, ServerInfo, ListToolsResult + +class MockMcpSession: + """Mock MCP session for testing.""" + + def __init__(self): + self.tools = [ + Tool(name="add", description="Add numbers", inputSchema={}), + Tool(name="multiply", description="Multiply numbers", inputSchema={}) + ] + self.initialized = False + + async def initialize(self): + self.initialized = True + return InitializeResult( + protocolVersion="2025-06-18", + serverInfo=ServerInfo(name="Test Server", version="1.0.0"), + capabilities={} + ) + + async def list_tools(self): + if not self.initialized: + raise RuntimeError("Not initialized") + return ListToolsResult(tools=self.tools) + + async def call_tool(self, name: str, arguments: dict): + if not self.initialized: + raise RuntimeError("Not initialized") + + if name == "add": + result = arguments["a"] + arguments["b"] + elif name == "multiply": + result = arguments["a"] * arguments["b"] + else: + raise ValueError(f"Unknown tool: {name}") + + mock_result = Mock() + mock_result.isError = False + mock_result.content = [Mock(text=str(result))] + mock_result.structuredContent = {"result": result} + + return mock_result + +@pytest.fixture +async def mock_session(): + """Pytest fixture providing mock session.""" + return MockMcpSession() + +@pytest.mark.asyncio +async def test_client_initialization(mock_session): + """Test client initialization.""" + result = await mock_session.initialize() + assert result.serverInfo.name == "Test Server" + assert mock_session.initialized + +@pytest.mark.asyncio +async def test_tool_listing(mock_session): + """Test tool listing.""" + await mock_session.initialize() + tools = await mock_session.list_tools() + + assert len(tools.tools) == 2 + assert tools.tools[0].name == "add" + assert tools.tools[1].name == "multiply" + +@pytest.mark.asyncio +async def test_tool_calling(mock_session): + """Test tool calling.""" + await mock_session.initialize() + + # Test add tool + result = await mock_session.call_tool("add", {"a": 5, "b": 3}) + assert not result.isError + assert result.content[0].text == "8" + + # Test multiply tool + result = await mock_session.call_tool("multiply", {"a": 4, "b": 6}) + assert not result.isError + assert result.content[0].text == "24" + +@pytest.mark.asyncio +async def test_error_handling(mock_session): + """Test error handling.""" + await mock_session.initialize() + + with pytest.raises(ValueError): + await mock_session.call_tool("unknown_tool", {}) + +# Integration test with real client +@pytest.mark.integration +@pytest.mark.asyncio +async def test_real_client_integration(): + """Integration test with real MCP server.""" + # This would connect to a real server for integration testing + # server_params = StdioServerParameters(command="python", args=["test_server.py"]) + # + # async with stdio_client(server_params) as (read, write): + # async with ClientSession(read, write) as session: + # await session.initialize() + # tools = await session.list_tools() + # assert len(tools.tools) > 0 + pass +``` + +## Best practices + +### Client design guidelines + +- **Connection management** - Use connection pooling for high-throughput applications +- **Error handling** - Implement comprehensive error handling and retries +- **Caching** - Cache stable data like tool lists and resource schemas +- **Monitoring** - Track connection health and operation latency +- **Resource cleanup** - Always clean up connections and resources + +### Performance optimization + +- **Async operations** - Use async/await throughout for better concurrency +- **Connection reuse** - Pool connections for multiple operations +- **Batch operations** - Group related operations when possible +- **Smart caching** - Cache responses based on data volatility +- **Timeout management** - Set appropriate timeouts for operations + +### Security considerations + +- **Input validation** - Validate all data before sending to servers +- **Credential management** - Secure handling of authentication tokens +- **Transport security** - Use secure transports (HTTPS, authenticated connections) +- **Error information** - Don't expose sensitive data in error messages +- **Audit logging** - Log all operations for security monitoring + +## Next steps + +- **[OAuth for clients](oauth-clients.md)** - Implement client-side authentication +- **[Display utilities](display-utilities.md)** - UI helpers for client applications +- **[Parsing results](parsing-results.md)** - Handle complex tool responses +- **[Authentication](authentication.md)** - Understanding server-side authentication \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 1aba72cbd..74f9182c6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,17 +1,46 @@ -site_name: MCP Server -site_description: MCP Server -strict: true +site_name: MCP Python SDK +site_description: Python implementation of the Model Context Protocol (MCP) +strict: false -repo_name: modelcontextprotocol/python-sdk -repo_url: https://github.com/modelcontextprotocol/python-sdk +repo_name: mmacy/python-sdk +repo_url: https://github.com/mmacy/python-sdk edit_uri: edit/main/docs/ -site_url: https://modelcontextprotocol.github.io/python-sdk +site_url: https://mmacy.github.io/python-sdk # TODO(Marcelo): Add Anthropic copyright? # copyright: © Model Context Protocol 2025 to present nav: - Home: index.md + - Getting started: + - Quickstart: quickstart.md + - Installation: installation.md + - Core concepts: + - Servers: servers.md + - Resources: resources.md + - Tools: tools.md + - Prompts: prompts.md + - Context: context.md + - Advanced features: + - Images: images.md + - Authentication: authentication.md + - Sampling: sampling.md + - Elicitation: elicitation.md + - Progress & logging: progress-logging.md + - Transport & deployment: + - Running servers: running-servers.md + - Streamable HTTP: streamable-http.md + - ASGI integration: asgi-integration.md + - Client development: + - Writing clients: writing-clients.md + - OAuth for clients: oauth-clients.md + - Display utilities: display-utilities.md + - Parsing results: parsing-results.md + - Advanced usage: + - Low-level server: low-level-server.md + - Structured output: structured-output.md + - Completions: completions.md + - API reference: reference/ theme: name: "material" @@ -98,24 +127,23 @@ watch: plugins: - search - - social + # - social # Disabled due to Cairo dependency issues - glightbox - mkdocstrings: handlers: python: paths: [src/mcp] options: - relative_crossrefs: true + group_by_category: false members_order: source + relative_crossrefs: true separate_signature: true show_signature_annotations: true + show_source: false signature_crossrefs: true - group_by_category: false - # 3 because docs are in pages with an H2 just above them - heading_level: 3 import: - url: https://docs.python.org/3/objects.inv - url: https://docs.pydantic.dev/latest/objects.inv - url: https://typing-extensions.readthedocs.io/en/latest/objects.inv - api-autonav: - modules: ['src/mcp'] \ No newline at end of file + modules: ["src/mcp"] diff --git a/src/mcp/__init__.py b/src/mcp/__init__.py index e4f70f82a..8de2e4e47 100644 --- a/src/mcp/__init__.py +++ b/src/mcp/__init__.py @@ -1,15 +1,13 @@ -"""MCP Python SDK - Model Context Protocol implementation for Python. +"""An implementation of the [Model Context Protocol (MCP) specification](https://modelcontextprotocol.io/specification/latest) in Python. -The Model Context Protocol (MCP) allows applications to provide context for LLMs in a -standardized way, separating the concerns of providing context from the actual LLM -interaction. This Python SDK implements the full MCP specification, making it easy to: +Use the MCP Python SDK to: - Build MCP clients that can connect to any MCP server -- Create MCP servers that expose resources, prompts and tools +- Build MCP servers that expose resources, prompts, and tools - Use standard transports like stdio, SSE, and Streamable HTTP -- Handle all MCP protocol messages and lifecycle events +- Handle MCP protocol messages and lifecycle events -## Quick start - creating a server +## Example - create a [`FastMCP`][mcp.server.fastmcp.FastMCP] server ```python from mcp.server.fastmcp import FastMCP @@ -26,7 +24,7 @@ def add(a: int, b: int) -> int: mcp.run() ``` -## Quick start - creating a client +## Example - create a client ```python from mcp import ClientSession, StdioServerParameters, stdio_client @@ -42,7 +40,6 @@ def add(a: int, b: int) -> int: result = await session.call_tool("add", {"a": 5, "b": 3}) ``` -For more examples and documentation, see: https://modelcontextprotocol.io """ from .client.session import ClientSession From 7029b00eaaaacf667a7bbadc23b7842e3ef7edaf Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 18 Aug 2025 19:02:08 -0700 Subject: [PATCH 06/11] New examples docs (full-ingest style) (#6) * new example docs * fixed nav * fix doc index and TOC * examples feature matrix * populate transport column * link to example headings --- docs/asgi-integration.md | 762 -------------- docs/authentication.md | 596 ----------- docs/completions.md | 1053 -------------------- docs/context.md | 654 ------------ docs/display-utilities.md | 1287 ------------------------ docs/elicitation.md | 442 --------- docs/examples-authentication.md | 146 +++ docs/examples-clients.md | 127 +++ docs/examples-echo-servers.md | 75 ++ docs/examples-lowlevel-servers.md | 95 ++ docs/examples-quickstart.md | 52 + docs/examples-server-advanced.md | 95 ++ docs/examples-server-prompts.md | 42 + docs/examples-server-resources.md | 50 + docs/examples-server-tools.md | 76 ++ docs/examples-structured-output.md | 69 ++ docs/examples-transport-http.md | 91 ++ docs/images.md | 780 --------------- docs/index.md | 160 +-- docs/installation.md | 194 ---- docs/low-level-server.md | 1299 ------------------------ docs/oauth-clients.md | 971 ------------------ docs/parsing-results.md | 1333 ------------------------- docs/progress-logging.md | 226 ----- docs/prompts.md | 562 ----------- docs/quickstart.md | 140 --- docs/resources.md | 487 --------- docs/running-servers.md | 666 ------------- docs/sampling.md | 628 ------------ docs/servers.md | 353 ------- docs/streamable-http.md | 722 -------------- docs/structured-output.md | 1477 ---------------------------- docs/tools.md | 671 ------------- docs/writing-clients.md | 1078 -------------------- mkdocs.yml | 43 +- 35 files changed, 1014 insertions(+), 16488 deletions(-) delete mode 100644 docs/asgi-integration.md delete mode 100644 docs/authentication.md delete mode 100644 docs/completions.md delete mode 100644 docs/context.md delete mode 100644 docs/display-utilities.md delete mode 100644 docs/elicitation.md create mode 100644 docs/examples-authentication.md create mode 100644 docs/examples-clients.md create mode 100644 docs/examples-echo-servers.md create mode 100644 docs/examples-lowlevel-servers.md create mode 100644 docs/examples-quickstart.md create mode 100644 docs/examples-server-advanced.md create mode 100644 docs/examples-server-prompts.md create mode 100644 docs/examples-server-resources.md create mode 100644 docs/examples-server-tools.md create mode 100644 docs/examples-structured-output.md create mode 100644 docs/examples-transport-http.md delete mode 100644 docs/images.md delete mode 100644 docs/installation.md delete mode 100644 docs/low-level-server.md delete mode 100644 docs/oauth-clients.md delete mode 100644 docs/parsing-results.md delete mode 100644 docs/progress-logging.md delete mode 100644 docs/prompts.md delete mode 100644 docs/quickstart.md delete mode 100644 docs/resources.md delete mode 100644 docs/running-servers.md delete mode 100644 docs/sampling.md delete mode 100644 docs/servers.md delete mode 100644 docs/streamable-http.md delete mode 100644 docs/structured-output.md delete mode 100644 docs/tools.md delete mode 100644 docs/writing-clients.md diff --git a/docs/asgi-integration.md b/docs/asgi-integration.md deleted file mode 100644 index 268a4ff4c..000000000 --- a/docs/asgi-integration.md +++ /dev/null @@ -1,762 +0,0 @@ -# ASGI integration - -Learn how to integrate MCP servers with existing ASGI applications like FastAPI, Starlette, Django, and others. - -## Overview - -ASGI integration allows you to: - -- **Mount MCP servers** in existing web applications -- **Share middleware** and authentication between HTTP and MCP endpoints -- **Unified deployment** - serve both web API and MCP from the same process -- **Resource sharing** - use the same database connections and services - -## FastAPI integration - -### Basic integration - -```python -""" -FastAPI application with embedded MCP server. -""" - -from fastapi import FastAPI, HTTPException -from mcp.server.fastmcp import FastMCP - -# Create FastAPI app -app = FastAPI(title="API with MCP Integration") - -# Create MCP server -mcp = FastMCP("FastAPI MCP Server", stateless_http=True) - -@mcp.tool() -def process_api_data(data: str, operation: str = "uppercase") -> str: - """Process data with various operations.""" - operations = { - "uppercase": data.upper(), - "lowercase": data.lower(), - "reverse": data[::-1], - "length": str(len(data)) - } - - result = operations.get(operation) - if result is None: - raise ValueError(f"Unknown operation: {operation}") - - return result - -@mcp.resource("api://status") -def get_api_status() -> str: - """Get API server status.""" - return "API server is running and healthy" - -# Regular FastAPI endpoints -@app.get("/") -async def root(): - return {"message": "FastAPI with MCP integration", "mcp_endpoint": "/mcp"} - -@app.get("/health") -async def health(): - return {"status": "healthy", "mcp_available": True} - -@app.post("/api/process") -async def api_process(data: dict): - """Regular API endpoint that could leverage MCP tools.""" - if "text" not in data: - raise HTTPException(status_code=400, detail="Missing 'text' field") - - # In a real app, you might call MCP tools internally - text = data["text"] - operation = data.get("operation", "uppercase") - - # Simulate calling the MCP tool - result = process_api_data(text, operation) - - return {"processed": result, "operation": operation} - -# Mount MCP server -app.mount("/mcp", mcp.streamable_http_app()) - -# Lifecycle management -@app.on_event("startup") -async def startup(): - await mcp.session_manager.start() - -@app.on_event("shutdown") -async def shutdown(): - await mcp.session_manager.stop() - -# Run with: uvicorn app:app --host 0.0.0.0 --port 8000 -``` - -### Shared services integration - -```python -""" -FastAPI and MCP sharing database and services. -""" - -from contextlib import asynccontextmanager -from collections.abc import AsyncIterator -from dataclasses import dataclass -from fastapi import FastAPI, Depends -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession -import asyncpg - -@dataclass -class SharedServices: - """Shared services between FastAPI and MCP.""" - db_pool: asyncpg.Pool - cache: dict = None - - def __post_init__(self): - if self.cache is None: - self.cache = {} - -# Global services -services: SharedServices | None = None - -@asynccontextmanager -async def lifespan(app: FastAPI) -> AsyncIterator[None]: - """Shared lifespan for both FastAPI and MCP.""" - global services - - # Initialize shared services - db_pool = await asyncpg.create_pool( - "postgresql://user:pass@localhost/db", - min_size=5, - max_size=20 - ) - - services = SharedServices(db_pool=db_pool) - - # Start MCP server - await mcp.session_manager.start() - - try: - yield - finally: - # Cleanup - await mcp.session_manager.stop() - await db_pool.close() - -# Create FastAPI app with lifespan -app = FastAPI(lifespan=lifespan) - -# MCP server with access to shared services -mcp = FastMCP("Shared Services MCP") - -@mcp.tool() -async def query_database( - sql: str, - ctx: Context[ServerSession, None] -) -> list[dict]: - """Execute database query using shared connection pool.""" - if not services: - raise RuntimeError("Services not initialized") - - await ctx.info(f"Executing query: {sql}") - - async with services.db_pool.acquire() as conn: - rows = await conn.fetch(sql) - results = [dict(row) for row in rows] - - await ctx.info(f"Query returned {len(results)} rows") - return results - -@mcp.tool() -async def cache_operation( - key: str, - value: str | None = None, - ctx: Context[ServerSession, None] -) -> dict: - """Cache operations using shared cache.""" - if not services: - raise RuntimeError("Services not initialized") - - if value is not None: - # Set value - services.cache[key] = value - await ctx.info(f"Cached {key} = {value}") - return {"action": "set", "key": key, "value": value} - else: - # Get value - cached_value = services.cache.get(key) - await ctx.debug(f"Retrieved {key} = {cached_value}") - return {"action": "get", "key": key, "value": cached_value} - -# FastAPI endpoints using shared services -def get_services() -> SharedServices: - """Dependency to get shared services.""" - if not services: - raise RuntimeError("Services not initialized") - return services - -@app.get("/api/users") -async def list_users(services: SharedServices = Depends(get_services)): - """List users using shared database.""" - async with services.db_pool.acquire() as conn: - rows = await conn.fetch("SELECT id, name FROM users LIMIT 10") - return [{"id": row["id"], "name": row["name"]} for row in rows] - -@app.get("/api/cache/{key}") -async def get_cache(key: str, services: SharedServices = Depends(get_services)): - """Get cached value.""" - return {"key": key, "value": services.cache.get(key)} - -# Mount MCP -app.mount("/mcp", mcp.streamable_http_app()) -``` - -## Starlette integration - -### Multiple MCP servers - -```python -""" -Starlette app with multiple specialized MCP servers. -""" - -import contextlib -from starlette.applications import Starlette -from starlette.routing import Mount, Route -from starlette.responses import JSONResponse -from mcp.server.fastmcp import FastMCP - -# Create specialized MCP servers -user_mcp = FastMCP("User Management", stateless_http=True) -analytics_mcp = FastMCP("Analytics", stateless_http=True) -admin_mcp = FastMCP("Admin Tools", stateless_http=True) - -# User management tools -@user_mcp.tool() -def create_user(username: str, email: str) -> dict: - """Create a new user.""" - user_id = f"user_{hash(username) % 10000:04d}" - return { - "user_id": user_id, - "username": username, - "email": email, - "status": "created" - } - -@user_mcp.resource("user://{user_id}") -def get_user_profile(user_id: str) -> str: - """Get user profile information.""" - return f"""User Profile: {user_id} -Name: Example User -Email: user@example.com -Status: Active -Created: 2024-01-01""" - -# Analytics tools -@analytics_mcp.tool() -def calculate_metrics(data: list[float]) -> dict: - """Calculate analytics metrics.""" - if not data: - return {"error": "No data provided"} - - return { - "count": len(data), - "sum": sum(data), - "mean": sum(data) / len(data), - "min": min(data), - "max": max(data) - } - -@analytics_mcp.resource("metrics://daily") -def get_daily_metrics() -> str: - """Get daily metrics summary.""" - return """Daily Metrics Summary: -- Users: 1,234 active -- Requests: 45,678 total -- Errors: 12 (0.03%) -- Response time: 145ms avg""" - -# Admin tools -@admin_mcp.tool() -def system_status() -> dict: - """Get system status information.""" - return { - "status": "healthy", - "uptime": "5 days, 12 hours", - "memory_usage": "45%", - "cpu_usage": "23%", - "disk_usage": "67%" - } - -# Regular Starlette routes -async def homepage(request): - return JSONResponse({ - "message": "Multi-MCP Starlette Application", - "mcp_services": { - "users": "/users/mcp", - "analytics": "/analytics/mcp", - "admin": "/admin/mcp" - } - }) - -async def health_check(request): - return JSONResponse({"status": "healthy", "services": 3}) - -# Combined lifespan manager -@contextlib.asynccontextmanager -async def lifespan(app): - async with contextlib.AsyncExitStack() as stack: - await stack.enter_async_context(user_mcp.session_manager.run()) - await stack.enter_async_context(analytics_mcp.session_manager.run()) - await stack.enter_async_context(admin_mcp.session_manager.run()) - yield - -# Create Starlette application -app = Starlette( - routes=[ - Route("/", homepage), - Route("/health", health_check), - Mount("/users", user_mcp.streamable_http_app()), - Mount("/analytics", analytics_mcp.streamable_http_app()), - Mount("/admin", admin_mcp.streamable_http_app()), - ], - lifespan=lifespan -) - -# Run with: uvicorn app:app --host 0.0.0.0 --port 8000 -``` - -## Django integration - -### Django ASGI application - -```python -""" -Django ASGI integration with MCP server. - -Add to Django project's asgi.py file. -""" - -import os -from django.core.asgi import get_asgi_application -from starlette.applications import Starlette -from starlette.routing import Mount -from mcp.server.fastmcp import FastMCP - -# Configure Django -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings') - -# Get Django ASGI application -django_asgi_app = get_asgi_application() - -# Create MCP server -mcp = FastMCP("Django MCP Integration") - -@mcp.tool() -def django_model_stats() -> dict: - """Get Django model statistics.""" - # Import Django models - from django.contrib.auth.models import User - from myapp.models import MyModel # Your app models - - return { - "users_count": User.objects.count(), - "mymodel_count": MyModel.objects.count(), - "recent_users": User.objects.filter( - date_joined__gte=timezone.now() - timedelta(days=7) - ).count() - } - -@mcp.resource("django://models/{model_name}") -def get_model_info(model_name: str) -> str: - """Get information about Django models.""" - from django.apps import apps - - try: - model = apps.get_model(model_name) - field_info = [] - for field in model._meta.fields: - field_info.append(f"- {field.name}: {field.__class__.__name__}") - - return f"""Model: {model_name} -Fields: -{chr(10).join(field_info)} -Table: {model._meta.db_table}""" - - except LookupError: - return f"Model '{model_name}' not found" - -# Combined ASGI application -async def startup(): - await mcp.session_manager.start() - -async def shutdown(): - await mcp.session_manager.stop() - -# Create combined application -from starlette.applications import Starlette - -combined_app = Starlette() -combined_app.add_event_handler("startup", startup) -combined_app.add_event_handler("shutdown", shutdown) - -combined_app.mount("/mcp", mcp.streamable_http_app()) -combined_app.mount("/", django_asgi_app) - -application = combined_app -``` - -### Django management command - -```python -""" -Django management command to run MCP server. - -Save as: myapp/management/commands/run_mcp.py -""" - -from django.core.management.base import BaseCommand -from django.conf import settings -from mcp.server.fastmcp import FastMCP - -class Command(BaseCommand): - help = 'Run MCP server for Django integration' - - def add_arguments(self, parser): - parser.add_argument('--host', default='localhost', help='Host to bind to') - parser.add_argument('--port', type=int, default=8001, help='Port to bind to') - parser.add_argument('--debug', action='store_true', help='Enable debug mode') - - def handle(self, *args, **options): - from myapp.mcp_server import create_mcp_server - - mcp = create_mcp_server(debug=options['debug']) - - self.stdout.write( - self.style.SUCCESS( - f"Starting MCP server on {options['host']}:{options['port']}" - ) - ) - - mcp.run( - transport="streamable-http", - host=options['host'], - port=options['port'] - ) - -# Usage: python manage.py run_mcp --host 0.0.0.0 --port 8001 -``` - -## Middleware integration - -### Shared authentication middleware - -```python -""" -Shared authentication between FastAPI and MCP. -""" - -from fastapi import FastAPI, HTTPException, Depends -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from starlette.middleware.base import BaseHTTPMiddleware -from starlette.requests import Request -from starlette.responses import Response -from mcp.server.fastmcp import FastMCP -import jwt - -# Shared authentication logic -class AuthService: - SECRET_KEY = "your-secret-key" - - @classmethod - def verify_token(cls, token: str) -> dict | None: - try: - payload = jwt.decode(token, cls.SECRET_KEY, algorithms=["HS256"]) - return payload - except jwt.InvalidTokenError: - return None - - @classmethod - def create_token(cls, user_id: str) -> str: - return jwt.encode({"user_id": user_id}, cls.SECRET_KEY, algorithm="HS256") - -# FastAPI security -security = HTTPBearer() - -async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)): - token = credentials.credentials - payload = AuthService.verify_token(token) - if not payload: - raise HTTPException(status_code=401, detail="Invalid token") - return payload - -# Shared middleware -class AuthMiddleware(BaseHTTPMiddleware): - async def dispatch(self, request: Request, call_next): - # Skip auth for certain paths - if request.url.path in ["/health", "/login"]: - return await call_next(request) - - # Check authorization header - auth_header = request.headers.get("authorization") - if not auth_header or not auth_header.startswith("Bearer "): - return Response("Unauthorized", status_code=401) - - token = auth_header.split(" ")[1] - user = AuthService.verify_token(token) - if not user: - return Response("Invalid token", status_code=401) - - # Add user to request state - request.state.user = user - return await call_next(request) - -# FastAPI app with auth -app = FastAPI() -app.add_middleware(AuthMiddleware) - -@app.post("/login") -async def login(credentials: dict): - # Simple login (use proper authentication in production) - if credentials.get("username") == "admin" and credentials.get("password") == "secret": - token = AuthService.create_token("admin") - return {"access_token": token, "token_type": "bearer"} - raise HTTPException(status_code=401, detail="Invalid credentials") - -@app.get("/protected") -async def protected_endpoint(user: dict = Depends(get_current_user)): - return {"message": f"Hello {user['user_id']}", "protected": True} - -# MCP server (will inherit auth middleware when mounted) -mcp = FastMCP("Authenticated MCP") - -@mcp.tool() -def authenticated_tool(data: str) -> str: - """Tool that requires authentication.""" - # Authentication is handled by middleware - return f"Processed: {data}" - -# Mount MCP with auth middleware -app.mount("/mcp", mcp.streamable_http_app()) - -@app.on_event("startup") -async def startup(): - await mcp.session_manager.start() - -@app.on_event("shutdown") -async def shutdown(): - await mcp.session_manager.stop() -``` - -## Load balancing and scaling - -### Multi-instance deployment - -```python -""" -Load-balanced MCP deployment with shared state. -""" - -import redis.asyncio as redis -from fastapi import FastAPI -from mcp.server.fastmcp import FastMCP -import os -import json - -# Instance identification -INSTANCE_ID = os.getenv("INSTANCE_ID", "instance-1") -REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379") - -app = FastAPI(title=f"MCP Instance {INSTANCE_ID}") - -# Shared state via Redis -redis_client = redis.from_url(REDIS_URL) - -mcp = FastMCP(f"MCP {INSTANCE_ID}", stateless_http=True) - -@mcp.tool() -async def distributed_counter(operation: str = "increment") -> dict: - """Distributed counter across instances.""" - key = "distributed_counter" - - if operation == "increment": - new_value = await redis_client.incr(key) - return { - "operation": "increment", - "value": new_value, - "instance": INSTANCE_ID - } - elif operation == "get": - value = await redis_client.get(key) - return { - "operation": "get", - "value": int(value) if value else 0, - "instance": INSTANCE_ID - } - elif operation == "reset": - await redis_client.delete(key) - return { - "operation": "reset", - "value": 0, - "instance": INSTANCE_ID - } - else: - raise ValueError(f"Unknown operation: {operation}") - -@mcp.tool() -async def instance_info() -> dict: - """Get information about this instance.""" - return { - "instance_id": INSTANCE_ID, - "redis_connected": await redis_client.ping(), - "status": "healthy" - } - -@mcp.resource("cluster://status") -async def cluster_status() -> str: - """Get cluster status information.""" - # Store instance heartbeat - await redis_client.setex(f"instance:{INSTANCE_ID}", 60, "alive") - - # Get all active instances - keys = await redis_client.keys("instance:*") - active_instances = [key.decode().split(":")[1] for key in keys] - - return f"""Cluster Status: -Active Instances: {len(active_instances)} -Instance List: {', '.join(active_instances)} -Current Instance: {INSTANCE_ID} -Redis Connected: True""" - -# Health check endpoint -@app.get("/health") -async def health(): - return { - "instance": INSTANCE_ID, - "status": "healthy", - "redis": await redis_client.ping() - } - -# Mount MCP -app.mount("/mcp", mcp.streamable_http_app()) - -@app.on_event("startup") -async def startup(): - await mcp.session_manager.start() - # Register instance - await redis_client.setex(f"instance:{INSTANCE_ID}", 60, "alive") - -@app.on_event("shutdown") -async def shutdown(): - await mcp.session_manager.stop() - # Unregister instance - await redis_client.delete(f"instance:{INSTANCE_ID}") - await redis_client.close() -``` - -## Testing ASGI integration - -### Integration tests - -```python -""" -Integration tests for ASGI-mounted MCP servers. -""" - -import pytest -import asyncio -from httpx import AsyncClient -from fastapi import FastAPI -from mcp.server.fastmcp import FastMCP - -@pytest.fixture -async def test_app(): - """Create test FastAPI app with MCP integration.""" - app = FastAPI() - mcp = FastMCP("Test MCP") - - @mcp.tool() - def test_tool(value: str) -> str: - return f"Test: {value}" - - @app.get("/api/test") - async def api_test(): - return {"message": "API working"} - - app.mount("/mcp", mcp.streamable_http_app()) - - @app.on_event("startup") - async def startup(): - await mcp.session_manager.start() - - @app.on_event("shutdown") - async def shutdown(): - await mcp.session_manager.stop() - - return app - -@pytest.mark.asyncio -async def test_api_and_mcp_integration(test_app): - """Test both API and MCP endpoints work.""" - async with AsyncClient(app=test_app, base_url="http://test") as client: - # Test regular API endpoint - api_response = await client.get("/api/test") - assert api_response.status_code == 200 - assert api_response.json()["message"] == "API working" - - # Test MCP endpoint - mcp_request = { - "method": "initialize", - "params": { - "protocolVersion": "2025-06-18", - "clientInfo": {"name": "Test", "version": "1.0.0"}, - "capabilities": {} - } - } - - mcp_response = await client.post("/mcp", json=mcp_request) - assert mcp_response.status_code == 200 - - # Test MCP tool call - tool_request = { - "method": "tools/call", - "params": { - "name": "test_tool", - "arguments": {"value": "hello"} - } - } - - tool_response = await client.post("/mcp", json=tool_request) - assert tool_response.status_code == 200 -``` - -## Best practices - -### Design guidelines - -- **Separate concerns** - Keep web API and MCP functionality distinct -- **Share resources wisely** - Database pools, caches, but not request state -- **Use stateless MCP** - Better for scaling with web applications -- **Consistent authentication** - Use same auth system for both interfaces -- **Health checks** - Monitor both web and MCP endpoints - -### Performance considerations - -- **Connection pooling** - Share database and Redis connections -- **Async operations** - Use async/await throughout -- **Resource limits** - Set appropriate timeouts and limits -- **Monitoring** - Track both web and MCP metrics -- **Load balancing** - Distribute load across instances - -### Security best practices - -- **Unified authentication** - Same security model for both interfaces -- **Input validation** - Validate data at both API and MCP layers -- **Rate limiting** - Apply limits to both endpoint types -- **HTTPS only** - Use TLS for all production traffic -- **Audit logging** - Log access to both interfaces - -## Next steps - -- **[Running servers](running-servers.md)** - Production deployment strategies -- **[Streamable HTTP](streamable-http.md)** - Advanced HTTP transport configuration -- **[Authentication](authentication.md)** - Secure your integrated applications -- **[Writing clients](writing-clients.md)** - Build clients for integrated services \ No newline at end of file diff --git a/docs/authentication.md b/docs/authentication.md deleted file mode 100644 index 0da951d79..000000000 --- a/docs/authentication.md +++ /dev/null @@ -1,596 +0,0 @@ -# Authentication - -The MCP Python SDK implements OAuth 2.1 resource server functionality, allowing servers to validate tokens and protect resources. This follows the MCP authorization specification and RFC 9728. - -## OAuth 2.1 architecture - -MCP uses a three-party OAuth model: - -- **Authorization Server (AS)** - Handles user authentication and token issuance -- **Resource Server (RS)** - Your MCP server that validates tokens -- **Client** - Applications that access protected MCP resources - -## Basic authentication setup - -### Creating an authenticated server - -```python -from pydantic import AnyHttpUrl -from mcp.server.auth.provider import AccessToken, TokenVerifier -from mcp.server.auth.settings import AuthSettings -from mcp.server.fastmcp import FastMCP - -class SimpleTokenVerifier(TokenVerifier): - """Simple token verifier implementation.""" - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify and decode an access token.""" - # In production, validate JWT signatures, check expiration, etc. - if token.startswith("valid_"): - return AccessToken( - subject="user123", - scopes=["read", "write"], - expires_at=None, # Non-expiring for demo - client_id="demo_client" - ) - return None # Invalid token - -# Create server with authentication -mcp = FastMCP( - "Protected Weather Service", - token_verifier=SimpleTokenVerifier(), - auth=AuthSettings( - issuer_url=AnyHttpUrl("https://auth.example.com"), - resource_server_url=AnyHttpUrl("http://localhost:3001"), - required_scopes=["weather:read"] - ) -) - -@mcp.tool() -async def get_weather(city: str = "London") -> dict[str, str]: - """Get weather data - requires authentication.""" - return { - "city": city, - "temperature": "22°C", - "condition": "Sunny", - "humidity": "45%" - } -``` - -### Advanced token verification - -```python -import jwt -import time -from typing import Optional - -class JWTTokenVerifier(TokenVerifier): - """JWT-based token verifier.""" - - def __init__(self, public_key: str, algorithm: str = "RS256"): - self.public_key = public_key - self.algorithm = algorithm - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify JWT token.""" - try: - # Decode and verify JWT - payload = jwt.decode( - token, - self.public_key, - algorithms=[self.algorithm], - options={"verify_exp": True} - ) - - # Extract standard claims - subject = payload.get("sub") - scopes = payload.get("scope", "").split() - expires_at = payload.get("exp") - client_id = payload.get("client_id") - - if not subject: - return None - - return AccessToken( - subject=subject, - scopes=scopes, - expires_at=expires_at, - client_id=client_id, - raw_token=token - ) - - except jwt.InvalidTokenError: - return None - except Exception: - # Log error in production - return None - -# Use JWT verifier -jwt_verifier = JWTTokenVerifier(public_key="your-rsa-public-key") -mcp = FastMCP("JWT Protected Service", token_verifier=jwt_verifier) -``` - -## Scope-based authorization - -### Protecting resources by scope - -```python -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - -mcp = FastMCP("Scoped API", token_verifier=SimpleTokenVerifier()) - -@mcp.tool() -async def read_data(ctx: Context[ServerSession, None]) -> dict: - """Read data - requires 'read' scope.""" - # Access token information from context - if hasattr(ctx.session, 'access_token'): - token = ctx.session.access_token - if "read" not in token.scopes: - raise ValueError("Insufficient permissions: read scope required") - - await ctx.info(f"Data accessed by user: {token.subject}") - return {"data": "sensitive information", "user": token.subject} - - raise ValueError("Authentication required") - -@mcp.tool() -async def write_data(data: str, ctx: Context[ServerSession, None]) -> dict: - """Write data - requires 'write' scope.""" - if hasattr(ctx.session, 'access_token'): - token = ctx.session.access_token - if "write" not in token.scopes: - raise ValueError("Insufficient permissions: write scope required") - - await ctx.info(f"Data written by user: {token.subject}") - return {"status": "written", "data": data, "user": token.subject} - - raise ValueError("Authentication required") - -@mcp.tool() -async def admin_operation(ctx: Context[ServerSession, None]) -> dict: - """Admin operation - requires 'admin' scope.""" - if hasattr(ctx.session, 'access_token'): - token = ctx.session.access_token - if "admin" not in token.scopes: - raise ValueError("Admin access required") - - return {"message": "Admin operation completed", "admin": token.subject} - - raise ValueError("Authentication required") -``` - -### Custom authorization decorators - -```python -from functools import wraps - -def require_scopes(*required_scopes): - """Decorator to require specific scopes.""" - def decorator(func): - @wraps(func) - async def wrapper(*args, **kwargs): - # Find context argument - ctx = None - for arg in args: - if isinstance(arg, Context): - ctx = arg - break - - if not ctx: - raise ValueError("Context required for authorization") - - if not hasattr(ctx.session, 'access_token'): - raise ValueError("Authentication required") - - token = ctx.session.access_token - missing_scopes = set(required_scopes) - set(token.scopes) - - if missing_scopes: - raise ValueError(f"Missing required scopes: {', '.join(missing_scopes)}") - - return await func(*args, **kwargs) - - return wrapper - return decorator - -@mcp.tool() -@require_scopes("user:profile", "user:email") -async def get_user_profile(user_id: str, ctx: Context) -> dict: - """Get user profile - requires specific scopes.""" - token = ctx.session.access_token - await ctx.info(f"Profile accessed by {token.subject} for user {user_id}") - - return { - "user_id": user_id, - "name": "John Doe", - "email": "john@example.com", - "accessed_by": token.subject - } -``` - -## Token introspection - -### OAuth token introspection - -```python -import aiohttp -import json - -class IntrospectionTokenVerifier(TokenVerifier): - """Token verifier using OAuth introspection endpoint.""" - - def __init__(self, introspection_url: str, client_id: str, client_secret: str): - self.introspection_url = introspection_url - self.client_id = client_id - self.client_secret = client_secret - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify token using introspection endpoint.""" - try: - async with aiohttp.ClientSession() as session: - # Prepare introspection request - data = { - "token": token, - "token_type_hint": "access_token" - } - - auth = aiohttp.BasicAuth(self.client_id, self.client_secret) - - async with session.post( - self.introspection_url, - data=data, - auth=auth - ) as response: - if response.status != 200: - return None - - result = await response.json() - - # Check if token is active - if not result.get("active", False): - return None - - # Extract token information - return AccessToken( - subject=result.get("sub"), - scopes=result.get("scope", "").split(), - expires_at=result.get("exp"), - client_id=result.get("client_id"), - raw_token=token - ) - - except Exception: - # Log error in production - return None - -# Use introspection verifier -introspection_verifier = IntrospectionTokenVerifier( - introspection_url="https://auth.example.com/oauth/introspect", - client_id="mcp_server", - client_secret="server_secret" -) - -mcp = FastMCP("Introspection Server", token_verifier=introspection_verifier) -``` - -## Database-backed authorization - -### User and permission management - -```python -from dataclasses import dataclass -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager - -@dataclass -class User: - id: str - username: str - roles: list[str] - permissions: list[str] - -class AuthDatabase: - """Mock authentication database.""" - - def __init__(self): - self.users = { - "user123": User("user123", "alice", ["user"], ["read", "write"]), - "admin456": User("admin456", "admin", ["admin"], ["read", "write", "delete", "admin"]) - } - - async def get_user(self, user_id: str) -> User | None: - return self.users.get(user_id) - - async def verify_token(self, token: str) -> User | None: - # Simple token format: "token_userid" - if token.startswith("token_"): - user_id = token[6:] # Remove "token_" prefix - return await self.get_user(user_id) - return None - -@dataclass -class AppContext: - auth_db: AuthDatabase - -@asynccontextmanager -async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: - auth_db = AuthDatabase() - yield AppContext(auth_db=auth_db) - -class DatabaseTokenVerifier(TokenVerifier): - """Token verifier using database lookup.""" - - def __init__(self, auth_db: AuthDatabase): - self.auth_db = auth_db - - async def verify_token(self, token: str) -> AccessToken | None: - user = await self.auth_db.verify_token(token) - if user: - return AccessToken( - subject=user.id, - scopes=user.permissions, - expires_at=None, - client_id="database_client" - ) - return None - -# Create server with database authentication -auth_db = AuthDatabase() -mcp = FastMCP( - "Database Auth Server", - lifespan=app_lifespan, - token_verifier=DatabaseTokenVerifier(auth_db) -) - -@mcp.tool() -async def get_user_info( - user_id: str, - ctx: Context[ServerSession, AppContext] -) -> dict: - """Get user information - requires authentication.""" - # Verify user is authenticated - if not hasattr(ctx.session, 'access_token'): - raise ValueError("Authentication required") - - token = ctx.session.access_token - auth_db = ctx.request_context.lifespan_context.auth_db - - # Check if user can access this information - if token.subject != user_id and "admin" not in token.scopes: - raise ValueError("Insufficient permissions") - - user = await auth_db.get_user(user_id) - if not user: - raise ValueError("User not found") - - return { - "user_id": user.id, - "username": user.username, - "roles": user.roles, - "permissions": user.permissions - } -``` - -## Error handling and security - -### Authentication error handling - -```python -@mcp.tool() -async def secure_operation(data: str, ctx: Context) -> dict: - """Secure operation with comprehensive error handling.""" - try: - # Check authentication - if not hasattr(ctx.session, 'access_token'): - await ctx.warning("Unauthenticated access attempt") - raise ValueError("Authentication required") - - token = ctx.session.access_token - - # Check token expiration - if token.expires_at and token.expires_at < time.time(): - await ctx.warning(f"Expired token used by {token.subject}") - raise ValueError("Token expired") - - # Check required scopes - required_scopes = ["secure:access"] - missing_scopes = set(required_scopes) - set(token.scopes) - if missing_scopes: - await ctx.warning(f"Insufficient scopes for {token.subject}: missing {missing_scopes}") - raise ValueError(f"Missing required scopes: {', '.join(missing_scopes)}") - - # Log successful access - await ctx.info(f"Secure operation accessed by {token.subject}") - - # Perform secure operation - return { - "result": f"Processed: {data}", - "user": token.subject, - "timestamp": time.time() - } - - except ValueError as e: - await ctx.error(f"Authorization failed: {e}") - raise - except Exception as e: - await ctx.error(f"Unexpected error in secure operation: {e}") - raise ValueError("Internal server error") -``` - -### Rate limiting by user - -```python -import time -from collections import defaultdict - -class RateLimiter: - """Simple rate limiter by user.""" - - def __init__(self, requests_per_minute: int = 60): - self.requests_per_minute = requests_per_minute - self.requests = defaultdict(list) - - def is_allowed(self, user_id: str) -> bool: - """Check if user is within rate limits.""" - now = time.time() - minute_ago = now - 60 - - # Clean old requests - self.requests[user_id] = [ - req_time for req_time in self.requests[user_id] - if req_time > minute_ago - ] - - # Check if under limit - if len(self.requests[user_id]) >= self.requests_per_minute: - return False - - # Record this request - self.requests[user_id].append(now) - return True - -# Global rate limiter -rate_limiter = RateLimiter(requests_per_minute=100) - -def rate_limited(func): - """Decorator to apply rate limiting.""" - @wraps(func) - async def wrapper(*args, **kwargs): - # Find context - ctx = None - for arg in args: - if isinstance(arg, Context): - ctx = arg - break - - if ctx and hasattr(ctx.session, 'access_token'): - user_id = ctx.session.access_token.subject - - if not rate_limiter.is_allowed(user_id): - await ctx.warning(f"Rate limit exceeded for user {user_id}") - raise ValueError("Rate limit exceeded") - - return await func(*args, **kwargs) - - return wrapper - -@mcp.tool() -@rate_limited -async def api_call(endpoint: str, ctx: Context) -> dict: - """Rate-limited API call.""" - token = ctx.session.access_token - await ctx.info(f"API call to {endpoint} by {token.subject}") - - return { - "endpoint": endpoint, - "user": token.subject, - "result": "API response data" - } -``` - -## Testing authentication - -### Unit testing with mock tokens - -```python -import pytest -from unittest.mock import Mock, AsyncMock - -@pytest.mark.asyncio -async def test_authenticated_tool(): - """Test tool with authentication.""" - # Create mock context with token - mock_ctx = Mock() - mock_ctx.session = Mock() - mock_ctx.session.access_token = AccessToken( - subject="test_user", - scopes=["read", "write"], - expires_at=None, - client_id="test_client" - ) - mock_ctx.info = AsyncMock() - - # Test authenticated function - @require_scopes("read") - async def test_function(data: str, ctx: Context) -> dict: - await ctx.info("Function called") - return {"data": data, "user": ctx.session.access_token.subject} - - result = await test_function("test", mock_ctx) - - assert result["data"] == "test" - assert result["user"] == "test_user" - mock_ctx.info.assert_called_once() - -@pytest.mark.asyncio -async def test_insufficient_scopes(): - """Test scope enforcement.""" - mock_ctx = Mock() - mock_ctx.session = Mock() - mock_ctx.session.access_token = AccessToken( - subject="test_user", - scopes=["read"], # Missing 'write' scope - expires_at=None, - client_id="test_client" - ) - - @require_scopes("read", "write") - async def test_function(ctx: Context) -> dict: - return {"result": "success"} - - with pytest.raises(ValueError, match="Missing required scopes"): - await test_function(mock_ctx) -``` - -## Production considerations - -### Security best practices - -- **Validate all tokens** - Never trust client-provided tokens -- **Use HTTPS only** - All authentication must happen over secure connections -- **Implement proper logging** - Log authentication events for security monitoring -- **Rate limiting** - Prevent abuse with per-user rate limits -- **Token expiration** - Use short-lived tokens with refresh capabilities -- **Scope minimization** - Grant minimum required permissions - -### Performance optimization - -- **Token caching** - Cache validated tokens to reduce verification overhead -- **Connection pooling** - Reuse HTTP connections for introspection -- **Database optimization** - Index user/permission lookup tables -- **Async operations** - Use async/await for all I/O operations - -### Monitoring and alerting - -```python -import logging - -# Configure security logger -security_logger = logging.getLogger("security") - -@mcp.tool() -async def monitored_operation(ctx: Context) -> dict: - """Operation with security monitoring.""" - if not hasattr(ctx.session, 'access_token'): - security_logger.warning("Unauthenticated access attempt") - raise ValueError("Authentication required") - - token = ctx.session.access_token - - # Log successful access - security_logger.info(f"Secure access by {token.subject} with scopes {token.scopes}") - - # Check for suspicious patterns - if "admin" in token.scopes and token.subject != "admin_user": - security_logger.warning(f"Non-admin user {token.subject} has admin scopes") - - return {"status": "success", "user": token.subject} -``` - -## Next steps - -- **[Server deployment](running-servers.md)** - Deploy authenticated servers -- **[Client authentication](oauth-clients.md)** - Implement client-side OAuth -- **[Advanced security](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization)** - Full MCP authorization spec -- **[Monitoring](progress-logging.md)** - Security logging and monitoring \ No newline at end of file diff --git a/docs/completions.md b/docs/completions.md deleted file mode 100644 index 203f0e20a..000000000 --- a/docs/completions.md +++ /dev/null @@ -1,1053 +0,0 @@ -# Completions - -Learn how to integrate LLM text generation and completions into your MCP servers for advanced AI-powered functionality. - -## Overview - -MCP completions enable: - -- **LLM integration** - Generate text using language models -- **Smart automation** - AI-powered content generation and analysis -- **Interactive workflows** - Dynamic responses based on user input -- **Content enhancement** - Improve and expand existing content -- **Decision support** - AI-assisted decision making - -## Basic completions - -### Simple text completion - -```python -""" -Basic LLM completions in MCP servers. -""" - -from mcp.server.fastmcp import FastMCP -from mcp.types import SamplingMessage, Role -import asyncio -import os - -# Create server -mcp = FastMCP("AI Completion Server") - -@mcp.tool() -async def complete_text( - prompt: str, - max_tokens: int = 100, - temperature: float = 0.7 -) -> str: - """Complete text using LLM.""" - - # Create sampling message - message = SamplingMessage( - role=Role.USER, - content={"type": "text", "text": prompt} - ) - - # Request completion from client - try: - completion = await mcp.request_sampling( - messages=[message], - max_tokens=max_tokens, - temperature=temperature - ) - - if completion and completion.content: - content = completion.content[0] - if hasattr(content, 'text'): - return content.text - - return "No completion generated" - - except Exception as e: - return f"Error generating completion: {e}" - -@mcp.tool() -async def summarize_text( - text: str, - summary_length: str = "medium" -) -> str: - """Summarize text using LLM.""" - - length_instructions = { - "short": "in 1-2 sentences", - "medium": "in 3-5 sentences", - "long": "in 1-2 paragraphs" - } - - instruction = length_instructions.get(summary_length, "in 3-5 sentences") - - prompt = f"""Please summarize the following text {instruction}: - -{text} - -Summary:""" - - message = SamplingMessage( - role=Role.USER, - content={"type": "text", "text": prompt} - ) - - try: - completion = await mcp.request_sampling( - messages=[message], - max_tokens=200, - temperature=0.3 # Lower temperature for factual summaries - ) - - if completion and completion.content: - content = completion.content[0] - if hasattr(content, 'text'): - return content.text.strip() - - return "Could not generate summary" - - except Exception as e: - return f"Error generating summary: {e}" - -@mcp.tool() -async def analyze_sentiment(text: str) -> dict: - """Analyze sentiment of text using LLM.""" - - prompt = f"""Analyze the sentiment of the following text and provide: -1. Overall sentiment (positive, negative, or neutral) -2. Confidence score (0-1) -3. Key emotional indicators -4. Brief explanation - -Text: "{text}" - -Please respond in JSON format:""" - - message = SamplingMessage( - role=Role.USER, - content={"type": "text", "text": prompt} - ) - - try: - completion = await mcp.request_sampling( - messages=[message], - max_tokens=150, - temperature=0.2 - ) - - if completion and completion.content: - content = completion.content[0] - if hasattr(content, 'text'): - import json - try: - return json.loads(content.text) - except json.JSONDecodeError: - return {"error": "Could not parse response as JSON", "raw_response": content.text} - - return {"error": "No response generated"} - - except Exception as e: - return {"error": f"Error analyzing sentiment: {e}"} - -if __name__ == "__main__": - mcp.run() -``` - -## Conversational completions - -### Multi-turn conversations - -```python -""" -Multi-turn conversation handling with completions. -""" - -from typing import List, Dict, Any, Optional -from dataclasses import dataclass, field -from datetime import datetime -import uuid - -@dataclass -class ConversationTurn: - """Represents a single conversation turn.""" - id: str - role: Role - content: str - timestamp: datetime = field(default_factory=datetime.now) - metadata: Dict[str, Any] = field(default_factory=dict) - -@dataclass -class Conversation: - """Represents a conversation with multiple turns.""" - id: str - turns: List[ConversationTurn] = field(default_factory=list) - created_at: datetime = field(default_factory=datetime.now) - updated_at: datetime = field(default_factory=datetime.now) - metadata: Dict[str, Any] = field(default_factory=dict) - - def add_turn(self, role: Role, content: str, metadata: Dict[str, Any] = None): - """Add a new turn to the conversation.""" - turn = ConversationTurn( - id=str(uuid.uuid4()), - role=role, - content=content, - metadata=metadata or {} - ) - self.turns.append(turn) - self.updated_at = datetime.now() - return turn - - def get_messages(self) -> List[SamplingMessage]: - """Convert conversation turns to sampling messages.""" - messages = [] - for turn in self.turns: - message = SamplingMessage( - role=turn.role, - content={"type": "text", "text": turn.content} - ) - messages.append(message) - return messages - -class ConversationManager: - """Manages multiple conversations.""" - - def __init__(self): - self.conversations: Dict[str, Conversation] = {} - - def create_conversation(self, initial_message: str = None, metadata: Dict[str, Any] = None) -> str: - """Create a new conversation.""" - conversation_id = str(uuid.uuid4()) - conversation = Conversation( - id=conversation_id, - metadata=metadata or {} - ) - - if initial_message: - conversation.add_turn(Role.USER, initial_message) - - self.conversations[conversation_id] = conversation - return conversation_id - - def add_message(self, conversation_id: str, role: Role, content: str, metadata: Dict[str, Any] = None) -> bool: - """Add a message to an existing conversation.""" - if conversation_id not in self.conversations: - return False - - self.conversations[conversation_id].add_turn(role, content, metadata) - return True - - def get_conversation(self, conversation_id: str) -> Optional[Conversation]: - """Get a conversation by ID.""" - return self.conversations.get(conversation_id) - - def list_conversations(self) -> List[Dict[str, Any]]: - """List all conversations with metadata.""" - return [ - { - "id": conv.id, - "turn_count": len(conv.turns), - "created_at": conv.created_at.isoformat(), - "updated_at": conv.updated_at.isoformat(), - "metadata": conv.metadata - } - for conv in self.conversations.values() - ] - -# Global conversation manager -conversation_manager = ConversationManager() - -@mcp.tool() -def start_conversation(initial_message: str = "", context: str = "") -> dict: - """Start a new conversation.""" - metadata = {"context": context} if context else {} - - conversation_id = conversation_manager.create_conversation( - initial_message=initial_message if initial_message else None, - metadata=metadata - ) - - return { - "conversation_id": conversation_id, - "message": "Conversation started", - "initial_message": initial_message - } - -@mcp.tool() -async def chat(conversation_id: str, message: str, temperature: float = 0.7) -> dict: - """Continue a conversation with a new message.""" - conversation = conversation_manager.get_conversation(conversation_id) - if not conversation: - return {"error": f"Conversation {conversation_id} not found"} - - # Add user message - conversation.add_turn(Role.USER, message) - - # Get conversation history - messages = conversation.get_messages() - - try: - # Request completion with full conversation context - completion = await mcp.request_sampling( - messages=messages, - max_tokens=300, - temperature=temperature - ) - - if completion and completion.content: - content = completion.content[0] - if hasattr(content, 'text'): - response_text = content.text.strip() - - # Add assistant response to conversation - conversation.add_turn(Role.ASSISTANT, response_text) - - return { - "conversation_id": conversation_id, - "response": response_text, - "turn_count": len(conversation.turns) - } - - return {"error": "No response generated"} - - except Exception as e: - return {"error": f"Error generating response: {e}"} - -@mcp.tool() -def get_conversation_history(conversation_id: str) -> dict: - """Get the full history of a conversation.""" - conversation = conversation_manager.get_conversation(conversation_id) - if not conversation: - return {"error": f"Conversation {conversation_id} not found"} - - return { - "conversation_id": conversation_id, - "created_at": conversation.created_at.isoformat(), - "updated_at": conversation.updated_at.isoformat(), - "turn_count": len(conversation.turns), - "turns": [ - { - "id": turn.id, - "role": turn.role.value, - "content": turn.content, - "timestamp": turn.timestamp.isoformat(), - "metadata": turn.metadata - } - for turn in conversation.turns - ], - "metadata": conversation.metadata - } - -@mcp.tool() -def list_conversations() -> dict: - """List all active conversations.""" - return { - "conversations": conversation_manager.list_conversations(), - "total_count": len(conversation_manager.conversations) - } - -@mcp.tool() -async def conversation_summary(conversation_id: str) -> dict: - """Generate a summary of a conversation.""" - conversation = conversation_manager.get_conversation(conversation_id) - if not conversation: - return {"error": f"Conversation {conversation_id} not found"} - - if len(conversation.turns) < 2: - return {"error": "Not enough conversation turns to summarize"} - - # Build conversation text - conversation_text = "" - for turn in conversation.turns: - role_name = "User" if turn.role == Role.USER else "Assistant" - conversation_text += f"{role_name}: {turn.content}\\n\\n" - - prompt = f"""Please provide a concise summary of the following conversation: - -{conversation_text} - -Summary:""" - - message = SamplingMessage( - role=Role.USER, - content={"type": "text", "text": prompt} - ) - - try: - completion = await mcp.request_sampling( - messages=[message], - max_tokens=200, - temperature=0.3 - ) - - if completion and completion.content: - content = completion.content[0] - if hasattr(content, 'text'): - return { - "conversation_id": conversation_id, - "summary": content.text.strip(), - "turn_count": len(conversation.turns) - } - - return {"error": "Could not generate summary"} - - except Exception as e: - return {"error": f"Error generating summary: {e}"} -``` - -## Specialized completion workflows - -### Content generation workflows - -```python -""" -Specialized workflows for content generation. -""" - -from typing import List, Dict, Any -from enum import Enum - -class ContentType(str, Enum): - """Types of content that can be generated.""" - BLOG_POST = "blog_post" - EMAIL = "email" - SOCIAL_MEDIA = "social_media" - DOCUMENTATION = "documentation" - CREATIVE_WRITING = "creative_writing" - TECHNICAL_SPEC = "technical_spec" - -class ToneStyle(str, Enum): - """Tone and style options.""" - PROFESSIONAL = "professional" - CASUAL = "casual" - FRIENDLY = "friendly" - FORMAL = "formal" - TECHNICAL = "technical" - CREATIVE = "creative" - -@mcp.tool() -async def generate_content( - content_type: str, - topic: str, - tone: str = "professional", - length: str = "medium", - target_audience: str = "general", - key_points: List[str] = None -) -> dict: - """Generate content based on specifications.""" - - # Validate inputs - try: - content_type_enum = ContentType(content_type) - tone_enum = ToneStyle(tone) - except ValueError as e: - return {"error": f"Invalid parameter: {e}"} - - # Build prompt based on content type - prompt_templates = { - ContentType.BLOG_POST: """Write a {length} blog post about "{topic}" with a {tone} tone for {target_audience}. - -Key points to cover: -{key_points} - -Please include: -- Engaging title -- Clear introduction -- Well-structured body with subheadings -- Compelling conclusion -- Call to action - -Blog post:""", - - ContentType.EMAIL: """Write a {tone} email about "{topic}" for {target_audience}. - -Key points to include: -{key_points} - -Please include: -- Clear subject line -- Professional greeting -- Concise body -- Appropriate closing - -Email:""", - - ContentType.SOCIAL_MEDIA: """Create a {tone} social media post about "{topic}" for {target_audience}. - -Key messages: -{key_points} - -Requirements: -- Engaging and shareable -- Appropriate hashtags -- Call to action -- Platform-optimized length - -Post:""", - - ContentType.DOCUMENTATION: """Write technical documentation about "{topic}" with a {tone} approach for {target_audience}. - -Key topics to cover: -{key_points} - -Include: -- Clear overview -- Step-by-step instructions -- Examples -- Troubleshooting tips - -Documentation:""", - - ContentType.CREATIVE_WRITING: """Write a creative piece about "{topic}" with a {tone} style for {target_audience}. - -Elements to include: -{key_points} - -Style requirements: -- {length} length -- Engaging narrative -- Rich descriptions -- Compelling characters/scenes - -Story:""", - - ContentType.TECHNICAL_SPEC: """Create a technical specification for "{topic}" with {tone} language for {target_audience}. - -Specifications to include: -{key_points} - -Format: -- Executive summary -- Technical requirements -- Implementation details -- Acceptance criteria - -Specification:""" - } - - # Format key points - key_points_text = "\\n".join(f"- {point}" for point in (key_points or ["General information about the topic"])) - - # Get prompt template - prompt_template = prompt_templates.get(content_type_enum, prompt_templates[ContentType.BLOG_POST]) - - # Format prompt - prompt = prompt_template.format( - topic=topic, - tone=tone, - length=length, - target_audience=target_audience, - key_points=key_points_text - ) - - message = SamplingMessage( - role=Role.USER, - content={"type": "text", "text": prompt} - ) - - try: - # Adjust parameters based on content type - max_tokens = { - "short": 200, - "medium": 500, - "long": 1000 - }.get(length, 500) - - temperature = { - ToneStyle.CREATIVE: 0.8, - ToneStyle.CASUAL: 0.7, - ToneStyle.FRIENDLY: 0.6, - ToneStyle.PROFESSIONAL: 0.5, - ToneStyle.FORMAL: 0.4, - ToneStyle.TECHNICAL: 0.3 - }.get(tone_enum, 0.5) - - completion = await mcp.request_sampling( - messages=[message], - max_tokens=max_tokens, - temperature=temperature - ) - - if completion and completion.content: - content = completion.content[0] - if hasattr(content, 'text'): - generated_content = content.text.strip() - - return { - "content": generated_content, - "content_type": content_type, - "tone": tone, - "length": length, - "target_audience": target_audience, - "word_count": len(generated_content.split()), - "character_count": len(generated_content) - } - - return {"error": "No content generated"} - - except Exception as e: - return {"error": f"Error generating content: {e}"} - -@mcp.tool() -async def improve_content( - original_content: str, - improvement_type: str = "clarity", - target_audience: str = "general" -) -> dict: - """Improve existing content based on specified criteria.""" - - improvement_instructions = { - "clarity": "Make the content clearer and easier to understand", - "engagement": "Make the content more engaging and compelling", - "conciseness": "Make the content more concise while retaining key information", - "formality": "Make the content more formal and professional", - "casualness": "Make the content more casual and conversational", - "technical": "Make the content more technically detailed and precise", - "accessibility": "Make the content more accessible to a broader audience" - } - - instruction = improvement_instructions.get(improvement_type, improvement_instructions["clarity"]) - - prompt = f"""Please improve the following content by focusing on: {instruction} - -Target audience: {target_audience} - -Original content: -{original_content} - -Improved content:""" - - message = SamplingMessage( - role=Role.USER, - content={"type": "text", "text": prompt} - ) - - try: - completion = await mcp.request_sampling( - messages=[message], - max_tokens=len(original_content.split()) + 200, # Allow for expansion - temperature=0.4 - ) - - if completion and completion.content: - content = completion.content[0] - if hasattr(content, 'text'): - improved_content = content.text.strip() - - return { - "original_content": original_content, - "improved_content": improved_content, - "improvement_type": improvement_type, - "target_audience": target_audience, - "original_word_count": len(original_content.split()), - "improved_word_count": len(improved_content.split()), - "change_ratio": len(improved_content.split()) / len(original_content.split()) - } - - return {"error": "Could not improve content"} - - except Exception as e: - return {"error": f"Error improving content: {e}"} - -@mcp.tool() -async def generate_variations( - base_content: str, - variation_count: int = 3, - variation_type: str = "tone" -) -> dict: - """Generate multiple variations of content.""" - - if variation_count > 5: - return {"error": "Maximum 5 variations allowed"} - - variation_instructions = { - "tone": [ - "professional and formal", - "friendly and conversational", - "enthusiastic and energetic", - "calm and measured", - "authoritative and confident" - ], - "length": [ - "much more concise", - "more detailed and expanded", - "moderately shorter", - "significantly longer", - "with added examples" - ], - "style": [ - "more creative and artistic", - "more technical and precise", - "more storytelling focused", - "more data-driven", - "more action-oriented" - ] - } - - instructions = variation_instructions.get(variation_type, variation_instructions["tone"]) - - variations = [] - - for i in range(variation_count): - instruction = instructions[i % len(instructions)] - - prompt = f"""Please rewrite the following content to be {instruction}: - -Original content: -{base_content} - -Rewritten content:""" - - message = SamplingMessage( - role=Role.USER, - content={"type": "text", "text": prompt} - ) - - try: - completion = await mcp.request_sampling( - messages=[message], - max_tokens=len(base_content.split()) + 100, - temperature=0.6 - ) - - if completion and completion.content: - content = completion.content[0] - if hasattr(content, 'text'): - variations.append({ - "variation_id": i + 1, - "instruction": instruction, - "content": content.text.strip(), - "word_count": len(content.text.split()) - }) - - except Exception as e: - variations.append({ - "variation_id": i + 1, - "instruction": instruction, - "error": str(e) - }) - - return { - "base_content": base_content, - "variation_type": variation_type, - "variations": variations, - "base_word_count": len(base_content.split()) - } -``` - -## Advanced completion techniques - -### Structured generation - -```python -""" -Advanced completion techniques with structured output. -""" - -import json -from typing import Any, Dict, List, Optional -from pydantic import BaseModel, Field - -class GenerationConfig(BaseModel): - """Configuration for structured generation.""" - max_tokens: int = Field(default=500, ge=50, le=2000) - temperature: float = Field(default=0.7, ge=0.0, le=2.0) - format: str = Field(default="text", pattern="^(text|json|markdown|html)$") - include_reasoning: bool = Field(default=False) - quality_check: bool = Field(default=True) - -class StructuredPrompt(BaseModel): - """Structured prompt with constraints.""" - task: str = Field(..., description="The main task to accomplish") - context: Optional[str] = Field(None, description="Additional context") - constraints: List[str] = Field(default_factory=list, description="Generation constraints") - examples: List[str] = Field(default_factory=list, description="Example outputs") - output_schema: Optional[Dict[str, Any]] = Field(None, description="Expected output schema") - -@mcp.tool() -async def structured_generation( - prompt_config: Dict[str, Any], - generation_config: Dict[str, Any] = None -) -> Dict[str, Any]: - """Generate structured content with advanced controls.""" - - try: - # Validate configurations - prompt = StructuredPrompt(**prompt_config) - config = GenerationConfig(**(generation_config or {})) - - # Build structured prompt - system_parts = [ - f"Task: {prompt.task}" - ] - - if prompt.context: - system_parts.append(f"Context: {prompt.context}") - - if prompt.constraints: - system_parts.append("Constraints:") - system_parts.extend(f"- {constraint}" for constraint in prompt.constraints) - - if prompt.examples: - system_parts.append("Examples:") - system_parts.extend(f"Example: {example}" for example in prompt.examples) - - if config.format == "json" and prompt.output_schema: - system_parts.append(f"Output format: JSON following this schema: {json.dumps(prompt.output_schema)}") - elif config.format == "json": - system_parts.append("Output format: Valid JSON") - elif config.format == "markdown": - system_parts.append("Output format: Markdown") - elif config.format == "html": - system_parts.append("Output format: HTML") - - if config.include_reasoning: - system_parts.append("Please include your reasoning process before the final output.") - - if config.quality_check: - system_parts.append("Ensure high quality and accuracy in your response.") - - full_prompt = "\\n\\n".join(system_parts) - - message = SamplingMessage( - role=Role.USER, - content={"type": "text", "text": full_prompt} - ) - - completion = await mcp.request_sampling( - messages=[message], - max_tokens=config.max_tokens, - temperature=config.temperature - ) - - if completion and completion.content: - content = completion.content[0] - if hasattr(content, 'text'): - generated_text = content.text.strip() - - # Validate output format - validation_result = None - if config.format == "json": - try: - parsed_json = json.loads(generated_text) - validation_result = {"valid": True, "parsed": parsed_json} - - # Validate against schema if provided - if prompt.output_schema: - # Simple schema validation (could use jsonschema library) - validation_result["schema_valid"] = True - except json.JSONDecodeError as e: - validation_result = {"valid": False, "error": str(e)} - - return { - "success": True, - "generated_content": generated_text, - "format": config.format, - "validation": validation_result, - "config_used": config.dict(), - "prompt_used": prompt.dict(), - "word_count": len(generated_text.split()), - "character_count": len(generated_text) - } - - return {"success": False, "error": "No content generated"} - - except Exception as e: - return {"success": False, "error": f"Error in structured generation: {e}"} - -@mcp.tool() -async def chain_generation( - steps: List[Dict[str, Any]], - pass_outputs: bool = True -) -> Dict[str, Any]: - """Chain multiple generation steps together.""" - - if len(steps) > 10: - return {"error": "Maximum 10 steps allowed"} - - results = [] - accumulated_context = "" - - for i, step_config in enumerate(steps): - step_id = i + 1 - - try: - # Add accumulated context if enabled - if pass_outputs and accumulated_context: - if "context" in step_config: - step_config["context"] += f"\\n\\nPrevious outputs:\\n{accumulated_context}" - else: - step_config["context"] = f"Previous outputs:\\n{accumulated_context}" - - # Execute generation step - step_result = await structured_generation(step_config) - - if step_result.get("success"): - generated_content = step_result["generated_content"] - - results.append({ - "step_id": step_id, - "success": True, - "content": generated_content, - "config": step_config, - "details": step_result - }) - - # Add to accumulated context - if pass_outputs: - accumulated_context += f"\\nStep {step_id}: {generated_content}\\n" - else: - results.append({ - "step_id": step_id, - "success": False, - "error": step_result.get("error"), - "config": step_config - }) - break # Stop on error - - except Exception as e: - results.append({ - "step_id": step_id, - "success": False, - "error": str(e), - "config": step_config - }) - break - - return { - "chain_success": all(result["success"] for result in results), - "steps_completed": len(results), - "total_steps": len(steps), - "results": results, - "final_output": results[-1]["content"] if results and results[-1]["success"] else None - } - -@mcp.tool() -async def iterative_refinement( - initial_prompt: str, - refinement_instructions: List[str], - max_iterations: int = 3 -) -> Dict[str, Any]: - """Iteratively refine generated content.""" - - if max_iterations > 5: - return {"error": "Maximum 5 iterations allowed"} - - iterations = [] - current_content = "" - - # Generate initial content - message = SamplingMessage( - role=Role.USER, - content={"type": "text", "text": initial_prompt} - ) - - try: - completion = await mcp.request_sampling( - messages=[message], - max_tokens=500, - temperature=0.7 - ) - - if completion and completion.content: - content = completion.content[0] - if hasattr(content, 'text'): - current_content = content.text.strip() - - iterations.append({ - "iteration": 0, - "type": "initial", - "prompt": initial_prompt, - "content": current_content, - "word_count": len(current_content.split()) - }) - - # Apply refinements - for i, instruction in enumerate(refinement_instructions[:max_iterations]): - if not current_content: - break - - refinement_prompt = f"""Please refine the following content based on this instruction: {instruction} - -Current content: -{current_content} - -Refined content:""" - - message = SamplingMessage( - role=Role.USER, - content={"type": "text", "text": refinement_prompt} - ) - - try: - completion = await mcp.request_sampling( - messages=[message], - max_tokens=600, - temperature=0.5 - ) - - if completion and completion.content: - content = completion.content[0] - if hasattr(content, 'text'): - refined_content = content.text.strip() - - iterations.append({ - "iteration": i + 1, - "type": "refinement", - "instruction": instruction, - "previous_content": current_content, - "refined_content": refined_content, - "word_count": len(refined_content.split()), - "improvement": len(refined_content.split()) - len(current_content.split()) - }) - - current_content = refined_content - - except Exception as e: - iterations.append({ - "iteration": i + 1, - "type": "refinement", - "instruction": instruction, - "error": str(e) - }) - break - - return { - "initial_prompt": initial_prompt, - "refinement_instructions": refinement_instructions, - "iterations_completed": len(iterations), - "iterations": iterations, - "final_content": current_content, - "total_word_count": len(current_content.split()) if current_content else 0 - } - -if __name__ == "__main__": - mcp.run() -``` - -## Best practices - -### Design guidelines - -- **Clear prompts** - Write specific, unambiguous prompts -- **Context management** - Maintain relevant context across conversations -- **Error handling** - Gracefully handle completion failures -- **Rate limiting** - Implement appropriate rate limits for LLM calls -- **Cost optimization** - Monitor and optimize token usage - -### Performance optimization - -- **Prompt engineering** - Optimize prompts for better results -- **Temperature control** - Adjust temperature based on use case -- **Token management** - Efficiently manage max_tokens parameters -- **Caching** - Cache common completions to reduce API calls -- **Batch processing** - Group similar requests when possible - -### Quality assurance - -- **Output validation** - Validate generated content format and quality -- **Content filtering** - Filter inappropriate or irrelevant content -- **Fact checking** - Implement fact-checking for factual content -- **User feedback** - Collect feedback to improve generation quality -- **Version tracking** - Track prompt versions and performance - -## Next steps - -- **[Structured output](structured-output.md)** - Advanced output formatting -- **[Low-level server](low-level-server.md)** - Custom completion implementations -- **[Authentication](authentication.md)** - Secure LLM integrations -- **[Sampling](sampling.md)** - Understanding MCP sampling patterns \ No newline at end of file diff --git a/docs/context.md b/docs/context.md deleted file mode 100644 index cddd9f273..000000000 --- a/docs/context.md +++ /dev/null @@ -1,654 +0,0 @@ -# Context - -The Context object provides tools and resources with access to request information, server capabilities, and communication channels. It's automatically injected into functions that request it. - -## What is context? - -Context gives your tools and resources access to: - -- **Request metadata** - IDs, client information, progress tokens -- **Logging capabilities** - Send structured log messages to clients -- **Progress reporting** - Update clients on long-running operations -- **Resource reading** - Access other resources from within tools -- **User interaction** - Request additional input through elicitation -- **Server information** - Access to server configuration and state - -## Basic context usage - -### Getting context in functions - -Add a parameter with the `Context` type annotation to any tool or resource: - -```python -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - -mcp = FastMCP("Context Example") - -@mcp.tool() -async def my_tool(data: str, ctx: Context[ServerSession, None]) -> str: - """Tool that uses context capabilities.""" - await ctx.info(f"Processing data: {data}") - return f"Processed: {data}" - -@mcp.resource("info://{type}") -async def get_info(type: str, ctx: Context) -> str: - """Resource that logs access.""" - await ctx.debug(f"Accessed info resource: {type}") - return f"Information about {type}" -``` - -### Context properties - -```python -@mcp.tool() -async def context_info(ctx: Context) -> dict: - """Get information from the context.""" - return { - "request_id": ctx.request_id, - "client_id": ctx.client_id, - "server_name": ctx.fastmcp.name, - "debug_mode": ctx.fastmcp.settings.debug - } -``` - -## Logging and notifications - -### Log levels - -```python -@mcp.tool() -async def demonstrate_logging(message: str, ctx: Context) -> str: - """Demonstrate different log levels.""" - # Debug information (usually filtered out in production) - await ctx.debug(f"Debug: Starting to process '{message}'") - - # General information - await ctx.info(f"Info: Processing message of length {len(message)}") - - # Warning about potential issues - if len(message) > 100: - await ctx.warning("Warning: Message is quite long, processing may take time") - - # Error conditions - if not message.strip(): - await ctx.error("Error: Empty message provided") - raise ValueError("Message cannot be empty") - - return f"Processed: {message}" - -@mcp.tool() -async def custom_logging(level: str, message: str, ctx: Context) -> str: - """Send log with custom level and logger name.""" - await ctx.log( - level=level, - message=message, - logger_name="custom.processor" - ) - return f"Logged {level}: {message}" -``` - -### Structured logging - -```python -@mcp.tool() -async def process_file(filename: str, ctx: Context) -> dict: - """Process a file with structured logging.""" - await ctx.info(f"Starting file processing: {filename}") - - try: - # Simulate file processing - file_size = len(filename) * 100 # Mock size calculation - - await ctx.debug(f"File size calculated: {file_size} bytes") - - if file_size > 1000: - await ctx.warning(f"Large file detected: {file_size} bytes") - - # Process file (simulated) - processed_lines = file_size // 50 - await ctx.info(f"Processing complete: {processed_lines} lines processed") - - return { - "filename": filename, - "size": file_size, - "lines_processed": processed_lines, - "status": "success" - } - - except Exception as e: - await ctx.error(f"File processing failed: {e}") - raise -``` - -## Progress reporting - -### Basic progress updates - -```python -import asyncio - -@mcp.tool() -async def long_task(steps: int, ctx: Context) -> str: - """Demonstrate progress reporting.""" - await ctx.info(f"Starting task with {steps} steps") - - for i in range(steps): - # Simulate work - await asyncio.sleep(0.1) - - # Report progress - progress = (i + 1) / steps - await ctx.report_progress( - progress=progress, - total=1.0, - message=f"Completed step {i + 1} of {steps}" - ) - - await ctx.debug(f"Step {i + 1} completed") - - await ctx.info("Task completed successfully") - return f"Finished all {steps} steps" -``` - -### Advanced progress tracking - -```python -@mcp.tool() -async def multi_phase_task( - phase_sizes: list[int], - ctx: Context -) -> dict[str, any]: - """Task with multiple phases and detailed progress.""" - total_steps = sum(phase_sizes) - completed_steps = 0 - - await ctx.info(f"Starting multi-phase task: {len(phase_sizes)} phases, {total_steps} total steps") - - results = {} - - for phase_num, phase_size in enumerate(phase_sizes, 1): - phase_name = f"Phase {phase_num}" - await ctx.info(f"Starting {phase_name} ({phase_size} steps)") - - for step in range(phase_size): - # Simulate work - await asyncio.sleep(0.05) - - completed_steps += 1 - overall_progress = completed_steps / total_steps - phase_progress = (step + 1) / phase_size - - await ctx.report_progress( - progress=overall_progress, - total=1.0, - message=f"{phase_name}: Step {step + 1}/{phase_size} (Overall: {completed_steps}/{total_steps})" - ) - - results[phase_name] = f"Completed {phase_size} steps" - await ctx.info(f"{phase_name} completed") - - return { - "total_steps": total_steps, - "phases_completed": len(phase_sizes), - "results": results, - "status": "success" - } -``` - -## Resource reading - -### Reading resources from tools - -```python -@mcp.resource("config://{section}") -def get_config(section: str) -> str: - """Get configuration for a section.""" - configs = { - "database": "host=localhost port=5432 dbname=myapp", - "cache": "redis://localhost:6379/0", - "logging": "level=INFO handler=file" - } - return configs.get(section, "Configuration not found") - -@mcp.tool() -async def process_with_config(operation: str, ctx: Context) -> str: - """Tool that reads configuration from resources.""" - try: - # Read database configuration - db_config = await ctx.read_resource("config://database") - db_content = db_config.contents[0] - - if hasattr(db_content, 'text'): - config_text = db_content.text - await ctx.info(f"Using database config: {config_text}") - - # Read logging configuration - log_config = await ctx.read_resource("config://logging") - log_content = log_config.contents[0] - - if hasattr(log_content, 'text'): - log_text = log_content.text - await ctx.debug(f"Logging config: {log_text}") - - # Perform operation with configuration - return f"Operation '{operation}' completed with loaded configuration" - - except Exception as e: - await ctx.error(f"Failed to read configuration: {e}") - raise ValueError(f"Configuration error: {e}") - -@mcp.tool() -async def analyze_resource(resource_uri: str, ctx: Context) -> dict: - """Analyze content from any resource.""" - try: - resource_content = await ctx.read_resource(resource_uri) - - analysis = { - "uri": resource_uri, - "content_blocks": len(resource_content.contents), - "types": [] - } - - for content in resource_content.contents: - if hasattr(content, 'text'): - analysis["types"].append("text") - word_count = len(content.text.split()) - analysis["word_count"] = word_count - await ctx.info(f"Analyzed text resource: {word_count} words") - elif hasattr(content, 'data'): - analysis["types"].append("binary") - data_size = len(content.data) if content.data else 0 - analysis["data_size"] = data_size - await ctx.info(f"Analyzed binary resource: {data_size} bytes") - - return analysis - - except Exception as e: - await ctx.error(f"Resource analysis failed: {e}") - raise -``` - -## User interaction through elicitation - -### Basic elicitation - -```python -from pydantic import BaseModel, Field - -class UserPreferences(BaseModel): - """Schema for collecting user preferences.""" - theme: str = Field(description="Preferred theme (light/dark)") - language: str = Field(description="Preferred language code") - notifications: bool = Field(description="Enable notifications?") - -@mcp.tool() -async def configure_settings(ctx: Context) -> dict: - """Configure user settings through elicitation.""" - await ctx.info("Collecting user preferences...") - - result = await ctx.elicit( - message="Please configure your preferences:", - schema=UserPreferences - ) - - if result.action == "accept" and result.data: - preferences = result.data - await ctx.info(f"Settings configured: theme={preferences.theme}, language={preferences.language}") - - return { - "status": "configured", - "theme": preferences.theme, - "language": preferences.language, - "notifications": preferences.notifications - } - elif result.action == "decline": - await ctx.info("User declined to configure settings") - return {"status": "declined", "using_defaults": True} - else: - await ctx.warning("Settings configuration was cancelled") - return {"status": "cancelled"} -``` - -### Advanced elicitation patterns - -```python -class BookingRequest(BaseModel): - """Schema for restaurant booking.""" - date: str = Field(description="Preferred date (YYYY-MM-DD)") - time: str = Field(description="Preferred time (HH:MM)") - party_size: int = Field(description="Number of people", ge=1, le=20) - special_requests: str = Field(default="", description="Any special requests") - -@mcp.tool() -async def book_restaurant( - restaurant: str, - initial_date: str, - ctx: Context -) -> dict: - """Book restaurant with fallback options.""" - await ctx.info(f"Checking availability at {restaurant} for {initial_date}") - - # Simulate availability check - if initial_date == "2024-12-25": # Christmas - likely busy - await ctx.warning(f"No availability on {initial_date}") - - result = await ctx.elicit( - message=f"Sorry, {restaurant} is fully booked on {initial_date}. Would you like to try a different date?", - schema=BookingRequest - ) - - if result.action == "accept" and result.data: - booking = result.data - await ctx.info(f"Alternative booking confirmed for {booking.date} at {booking.time}") - - return { - "status": "booked", - "restaurant": restaurant, - "date": booking.date, - "time": booking.time, - "party_size": booking.party_size, - "special_requests": booking.special_requests, - "confirmation_id": f"BK{hash(booking.date + booking.time) % 10000:04d}" - } - else: - return {"status": "cancelled", "reason": "No alternative date selected"} - - else: - # Direct booking for available date - return { - "status": "booked", - "restaurant": restaurant, - "date": initial_date, - "confirmation_id": f"BK{hash(initial_date) % 10000:04d}" - } -``` - -## Server and session access - -### Server information access - -```python -@mcp.tool() -def server_status(ctx: Context) -> dict: - """Get detailed server status information.""" - settings = ctx.fastmcp.settings - - return { - "server": { - "name": ctx.fastmcp.name, - "instructions": ctx.fastmcp.instructions, - "debug_mode": settings.debug, - "log_level": settings.log_level - }, - "network": { - "host": settings.host, - "port": settings.port, - "mount_path": settings.mount_path, - "sse_path": settings.sse_path - }, - "features": { - "stateless_http": settings.stateless_http, - "json_response": getattr(settings, 'json_response', False) - } - } -``` - -### Session information - -```python -@mcp.tool() -def session_info(ctx: Context) -> dict: - """Get information about the current session.""" - session = ctx.session - - info = { - "request_id": ctx.request_id, - "client_id": ctx.client_id - } - - # Access client capabilities if available - if hasattr(session, 'client_params'): - client_params = session.client_params - info["client_capabilities"] = { - "name": getattr(client_params, 'clientInfo', {}).get('name', 'Unknown'), - "version": getattr(client_params, 'clientInfo', {}).get('version', 'Unknown') - } - - return info -``` - -## Lifespan context access - -### Accessing lifespan resources - -```python -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from dataclasses import dataclass - -class DatabaseConnection: - """Mock database connection.""" - def __init__(self, connection_string: str): - self.connection_string = connection_string - self.is_connected = False - - async def connect(self): - self.is_connected = True - return self - - async def disconnect(self): - self.is_connected = False - - async def query(self, sql: str) -> list[dict]: - if not self.is_connected: - raise RuntimeError("Database not connected") - return [{"id": 1, "name": "test", "sql": sql}] - -@dataclass -class AppContext: - """Application context with shared resources.""" - db: DatabaseConnection - api_key: str - cache_enabled: bool - -@asynccontextmanager -async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: - """Manage application lifecycle.""" - # Startup - db = DatabaseConnection("postgresql://localhost/myapp") - await db.connect() - - context = AppContext( - db=db, - api_key="secret-api-key-123", - cache_enabled=True - ) - - try: - yield context - finally: - # Shutdown - await db.disconnect() - -mcp = FastMCP("Database App", lifespan=app_lifespan) - -@mcp.tool() -async def query_data( - sql: str, - ctx: Context[ServerSession, AppContext] -) -> dict: - """Query database using lifespan context.""" - # Access lifespan context - app_ctx = ctx.request_context.lifespan_context - - await ctx.info(f"Executing query with API key: {app_ctx.api_key[:10]}...") - - if app_ctx.cache_enabled: - await ctx.debug("Cache is enabled for this query") - - # Use database connection from lifespan - results = await app_ctx.db.query(sql) - - await ctx.info(f"Query returned {len(results)} rows") - - return { - "sql": sql, - "results": results, - "cached": app_ctx.cache_enabled, - "connection_status": app_ctx.db.is_connected - } -``` - -## Advanced context patterns - -### Context middleware pattern - -```python -from functools import wraps - -def with_timing(func): - """Decorator to add timing information to context operations.""" - @wraps(func) - async def wrapper(*args, **kwargs): - # Find context in arguments - ctx = None - for arg in args: - if isinstance(arg, Context): - ctx = arg - break - - if ctx: - import time - start_time = time.time() - await ctx.debug(f"Starting {func.__name__}") - - try: - result = await func(*args, **kwargs) - duration = time.time() - start_time - await ctx.info(f"Completed {func.__name__} in {duration:.2f}s") - return result - except Exception as e: - duration = time.time() - start_time - await ctx.error(f"Failed {func.__name__} after {duration:.2f}s: {e}") - raise - else: - return await func(*args, **kwargs) - - return wrapper - -@mcp.tool() -@with_timing -async def timed_operation(data: str, ctx: Context) -> str: - """Operation with automatic timing.""" - await asyncio.sleep(0.5) # Simulate work - return f"Processed: {data}" -``` - -### Context validation - -```python -def require_debug_mode(func): - """Decorator to require debug mode for certain operations.""" - @wraps(func) - async def wrapper(*args, **kwargs): - ctx = None - for arg in args: - if isinstance(arg, Context): - ctx = arg - break - - if ctx and not ctx.fastmcp.settings.debug: - await ctx.error("Debug mode required for this operation") - raise ValueError("Debug mode required") - - return await func(*args, **kwargs) - - return wrapper - -@mcp.tool() -@require_debug_mode -async def debug_operation(ctx: Context) -> dict: - """Operation that requires debug mode.""" - await ctx.info("Performing debug operation") - return {"debug_info": "sensitive debug data"} -``` - -## Testing context functionality - -### Mocking context for testing - -```python -import pytest -from unittest.mock import AsyncMock, Mock - -@pytest.mark.asyncio -async def test_tool_with_context(): - # Create mock context - mock_ctx = Mock() - mock_ctx.info = AsyncMock() - mock_ctx.debug = AsyncMock() - mock_ctx.request_id = "test-123" - - # Test the tool function - @mcp.tool() - async def test_tool(data: str, ctx: Context) -> str: - await ctx.info(f"Processing: {data}") - return f"Result: {data}" - - result = await test_tool("test data", mock_ctx) - - assert result == "Result: test data" - mock_ctx.info.assert_called_once_with("Processing: test data") - -@pytest.mark.asyncio -async def test_progress_reporting(): - mock_ctx = Mock() - mock_ctx.report_progress = AsyncMock() - mock_ctx.info = AsyncMock() - - @mcp.tool() - async def progress_tool(steps: int, ctx: Context) -> str: - for i in range(steps): - await ctx.report_progress( - progress=(i + 1) / steps, - total=1.0, - message=f"Step {i + 1}" - ) - return "Complete" - - result = await progress_tool(3, mock_ctx) - - assert result == "Complete" - assert mock_ctx.report_progress.call_count == 3 -``` - -## Best practices - -### Context usage guidelines - -- **Check context availability** - Not all functions need context -- **Use appropriate log levels** - Debug for detailed info, info for general updates -- **Handle context errors gracefully** - Don't assume context operations always succeed -- **Minimize context overhead** - Don't over-log or spam progress updates - -### Performance considerations - -- **Async context operations** - All context methods are async, use await -- **Batch logging** - Group related log messages when possible -- **Progress update frequency** - Update progress reasonably, not on every tiny step -- **Resource reading caching** - Cache frequently accessed resource content - -### Security considerations - -- **Sensitive data in logs** - Never log passwords, tokens, or personal data -- **Context information exposure** - Be careful what server info you expose -- **Elicitation data validation** - Always validate data from user elicitation -- **Resource access control** - Validate resource URIs in read_resource calls - -## Next steps - -- **[Server lifecycle](servers.md)** - Understanding server startup and shutdown -- **[Advanced tools](tools.md)** - Building complex tools with context -- **[Progress patterns](progress-logging.md)** - Advanced progress reporting techniques -- **[Authentication context](authentication.md)** - Using context with authenticated requests \ No newline at end of file diff --git a/docs/display-utilities.md b/docs/display-utilities.md deleted file mode 100644 index 35cdac71a..000000000 --- a/docs/display-utilities.md +++ /dev/null @@ -1,1287 +0,0 @@ -# Display utilities - -Learn how to create user-friendly display utilities for MCP client applications, including formatters, visualizers, and interactive components. - -## Overview - -Display utilities provide: - -- **Rich formatting** - Beautiful output for terminal and web interfaces -- **Data visualization** - Charts, tables, and graphs from MCP data -- **Interactive components** - Progress bars, menus, and forms -- **Multi-format output** - HTML, markdown, JSON, and plain text - -## Text formatting - -### Rich console output - -```python -""" -Rich text formatting for MCP client output. -""" - -from rich.console import Console -from rich.table import Table -from rich.progress import Progress, TaskID -from rich.panel import Panel -from rich.syntax import Syntax -from rich.tree import Tree -import json - -class McpFormatter: - """Rich formatter for MCP client output.""" - - def __init__(self): - self.console = Console() - - def format_server_info(self, server_info: dict): - """Format server information.""" - panel = Panel.fit( - f"[bold cyan]{server_info.get('name', 'Unknown Server')}[/bold cyan]\n" - f"Version: {server_info.get('version', 'Unknown')}\n" - f"Protocol: {server_info.get('protocolVersion', 'Unknown')}", - title="[bold]Server Info[/bold]", - border_style="blue" - ) - self.console.print(panel) - - def format_tools_list(self, tools: list): - """Format tools list as a table.""" - table = Table(title="Available Tools") - table.add_column("Name", style="cyan", no_wrap=True) - table.add_column("Description", style="white") - table.add_column("Schema", style="dim") - - for tool in tools: - schema_preview = self._format_schema_preview(tool.get('inputSchema', {})) - table.add_row( - tool['name'], - tool.get('description', 'No description'), - schema_preview - ) - - self.console.print(table) - - def format_resources_list(self, resources: list): - """Format resources list as a tree.""" - tree = Tree("[bold]Resources[/bold]") - - # Group by scheme - schemes = {} - for resource in resources: - uri = resource.get('uri', '') - scheme = uri.split('://')[0] if '://' in uri else 'unknown' - if scheme not in schemes: - schemes[scheme] = [] - schemes[scheme].append(resource) - - for scheme, scheme_resources in schemes.items(): - scheme_branch = tree.add(f"[bold blue]{scheme}://[/bold blue]") - for resource in scheme_resources: - uri = resource.get('uri', '') - path = uri.split('://', 1)[-1] if '://' in uri else uri - name = resource.get('name', path) - description = resource.get('description', '') - - resource_text = f"[cyan]{name}[/cyan]" - if description: - resource_text += f" - {description}" - - scheme_branch.add(resource_text) - - self.console.print(tree) - - def format_tool_result(self, tool_name: str, result: dict): - """Format tool execution result.""" - success = result.get('success', True) - - # Header - status = "[green]✓[/green]" if success else "[red]✗[/red]" - self.console.print(f"\n{status} [bold]{tool_name}[/bold]") - - # Content - if 'content' in result: - for item in result['content']: - if isinstance(item, str): - self.console.print(f" {item}") - else: - self.console.print(f" {json.dumps(item, indent=2)}") - - # Structured output - if 'structured' in result and result['structured']: - self.console.print("\n[dim]Structured Output:[/dim]") - syntax = Syntax( - json.dumps(result['structured'], indent=2), - "json", - theme="monokai" - ) - self.console.print(syntax) - - # Error details - if not success and 'error' in result: - self.console.print(f"[red]Error: {result['error']}[/red]") - - def _format_schema_preview(self, schema: dict) -> str: - """Create a preview of the input schema.""" - if not schema or 'properties' not in schema: - return "No parameters" - - props = schema['properties'] - required = schema.get('required', []) - - preview_parts = [] - for prop_name, prop_info in list(props.items())[:3]: # Show first 3 - prop_type = prop_info.get('type', 'any') - is_required = prop_name in required - - prop_text = f"{prop_name}: {prop_type}" - if is_required: - prop_text = f"[bold]{prop_text}[/bold]" - - preview_parts.append(prop_text) - - preview = ", ".join(preview_parts) - if len(props) > 3: - preview += f", ... (+{len(props) - 3} more)" - - return preview - - def show_progress(self, description: str) -> TaskID: - """Show a progress bar.""" - progress = Progress() - task_id = progress.add_task(description, total=100) - progress.start() - return task_id - -# Usage example -async def formatted_client_example(): - """Example client with rich formatting.""" - formatter = McpFormatter() - - async with streamablehttp_client("http://localhost:8000/mcp") as (read, write, _): - async with ClientSession(read, write) as session: - # Initialize - init_result = await session.initialize() - formatter.format_server_info(init_result.serverInfo.__dict__) - - # List and format tools - tools = await session.list_tools() - formatter.format_tools_list([tool.__dict__ for tool in tools.tools]) - - # List and format resources - resources = await session.list_resources() - formatter.format_resources_list([res.__dict__ for res in resources.resources]) - - # Call tool with formatted output - if tools.tools: - result = await session.call_tool(tools.tools[0].name, {"test": "value"}) - formatter.format_tool_result( - tools.tools[0].name, - { - "success": not result.isError, - "content": [item.text for item in result.content if hasattr(item, 'text')] - } - ) - -if __name__ == "__main__": - import asyncio - asyncio.run(formatted_client_example()) -``` - -### Plain text formatting - -```python -""" -Simple text formatting for basic terminals. -""" - -class SimpleFormatter: - """Simple text formatter for basic output.""" - - def __init__(self, width: int = 80): - self.width = width - - def format_server_info(self, server_info: dict): - """Format server information.""" - print("=" * self.width) - print(f"SERVER: {server_info.get('name', 'Unknown')}") - print(f"Version: {server_info.get('version', 'Unknown')}") - print(f"Protocol: {server_info.get('protocolVersion', 'Unknown')}") - print("=" * self.width) - - def format_tools_list(self, tools: list): - """Format tools as a simple list.""" - print("\nAVAILABLE TOOLS:") - print("-" * 40) - - for i, tool in enumerate(tools, 1): - print(f"{i:2d}. {tool['name']}") - if tool.get('description'): - # Word wrap description - desc = tool['description'] - wrapped = self._wrap_text(desc, self.width - 6) - for line in wrapped: - print(f" {line}") - print() - - def format_resources_list(self, resources: list): - """Format resources as a simple list.""" - print("\nAVAILABLE RESOURCES:") - print("-" * 40) - - for i, resource in enumerate(resources, 1): - uri = resource.get('uri', '') - name = resource.get('name', uri) - print(f"{i:2d}. {name}") - print(f" URI: {uri}") - if resource.get('description'): - desc_lines = self._wrap_text(resource['description'], self.width - 6) - for line in desc_lines: - print(f" {line}") - print() - - def format_tool_result(self, tool_name: str, result: dict): - """Format tool result.""" - success = result.get('success', True) - status = "SUCCESS" if success else "ERROR" - - print(f"\nTOOL RESULT: {tool_name} [{status}]") - print("-" * 40) - - if 'content' in result: - for item in result['content']: - if isinstance(item, str): - for line in self._wrap_text(item, self.width): - print(line) - else: - print(json.dumps(item, indent=2)) - - if 'error' in result: - print(f"ERROR: {result['error']}") - - def _wrap_text(self, text: str, width: int) -> list[str]: - """Simple text wrapping.""" - words = text.split() - lines = [] - current_line = [] - current_length = 0 - - for word in words: - if current_length + len(word) + 1 > width: - if current_line: - lines.append(" ".join(current_line)) - current_line = [word] - current_length = len(word) - else: - lines.append(word[:width]) - else: - current_line.append(word) - current_length += len(word) + (1 if current_line else 0) - - if current_line: - lines.append(" ".join(current_line)) - - return lines - -# Usage example with simple formatting -def simple_client_example(): - """Example with simple text formatting.""" - formatter = SimpleFormatter() - - # Mock data for demonstration - server_info = { - "name": "Example MCP Server", - "version": "1.0.0", - "protocolVersion": "2025-06-18" - } - - tools = [ - { - "name": "calculate", - "description": "Perform mathematical calculations with support for basic arithmetic operations including addition, subtraction, multiplication, and division." - }, - { - "name": "format_text", - "description": "Format text with various options like uppercase, lowercase, title case, and more." - } - ] - - formatter.format_server_info(server_info) - formatter.format_tools_list(tools) -``` - -## Data visualization - -### Charts and graphs - -```python -""" -Data visualization utilities for MCP results. -""" - -import matplotlib.pyplot as plt -import pandas as pd -from typing import Any, Dict, List -import json -from io import BytesIO -import base64 - -class McpVisualizer: - """Data visualization for MCP results.""" - - def __init__(self, style: str = "seaborn-v0_8"): - plt.style.use(style) - self.fig_size = (10, 6) - - def visualize_data(self, data: Any, chart_type: str = "auto") -> str: - """Create visualization from MCP data.""" - if isinstance(data, dict): - return self._visualize_dict(data, chart_type) - elif isinstance(data, list): - return self._visualize_list(data, chart_type) - else: - return self._create_text_chart(str(data)) - - def _visualize_dict(self, data: dict, chart_type: str) -> str: - """Visualize dictionary data.""" - # Check if it's time series data - if self._is_time_series(data): - return self._create_time_series_chart(data) - - # Check if it's categorical data - if self._is_categorical(data): - if chart_type == "pie": - return self._create_pie_chart(data) - else: - return self._create_bar_chart(data) - - # Default to table - return self._create_table_chart(data) - - def _visualize_list(self, data: list, chart_type: str) -> str: - """Visualize list data.""" - if not data: - return self._create_text_chart("No data to display") - - # Check if it's a list of numbers - if all(isinstance(x, (int, float)) for x in data): - if chart_type == "histogram": - return self._create_histogram(data) - else: - return self._create_line_chart(data) - - # Check if it's a list of dictionaries - if all(isinstance(x, dict) for x in data): - return self._create_dataframe_chart(data) - - # Default to text representation - return self._create_text_chart("\\n".join(str(x) for x in data)) - - def _is_time_series(self, data: dict) -> bool: - """Check if data represents time series.""" - time_keys = {'time', 'date', 'timestamp', 'datetime'} - return any(key.lower() in time_keys for key in data.keys()) - - def _is_categorical(self, data: dict) -> bool: - """Check if data represents categorical values.""" - return all(isinstance(v, (int, float)) for v in data.values()) - - def _create_bar_chart(self, data: dict) -> str: - """Create bar chart from dictionary.""" - fig, ax = plt.subplots(figsize=self.fig_size) - - keys = list(data.keys()) - values = list(data.values()) - - bars = ax.bar(keys, values) - ax.set_title("Data Distribution") - ax.set_xlabel("Categories") - ax.set_ylabel("Values") - - # Add value labels on bars - for bar, value in zip(bars, values): - height = bar.get_height() - ax.text(bar.get_x() + bar.get_width()/2., height, - f'{value}', ha='center', va='bottom') - - plt.xticks(rotation=45, ha='right') - plt.tight_layout() - - return self._fig_to_base64() - - def _create_pie_chart(self, data: dict) -> str: - """Create pie chart from dictionary.""" - fig, ax = plt.subplots(figsize=self.fig_size) - - labels = list(data.keys()) - sizes = list(data.values()) - - ax.pie(sizes, labels=labels, autopct='%1.1f%%', startangle=90) - ax.set_title("Data Distribution") - - plt.tight_layout() - return self._fig_to_base64() - - def _create_line_chart(self, data: list) -> str: - """Create line chart from list of numbers.""" - fig, ax = plt.subplots(figsize=self.fig_size) - - ax.plot(range(len(data)), data, marker='o') - ax.set_title("Data Trend") - ax.set_xlabel("Index") - ax.set_ylabel("Value") - ax.grid(True, alpha=0.3) - - plt.tight_layout() - return self._fig_to_base64() - - def _create_histogram(self, data: list) -> str: - """Create histogram from list of numbers.""" - fig, ax = plt.subplots(figsize=self.fig_size) - - ax.hist(data, bins=min(20, len(data)//2), alpha=0.7, edgecolor='black') - ax.set_title("Data Distribution") - ax.set_xlabel("Value") - ax.set_ylabel("Frequency") - ax.grid(True, alpha=0.3) - - plt.tight_layout() - return self._fig_to_base64() - - def _create_dataframe_chart(self, data: list) -> str: - """Create chart from list of dictionaries.""" - df = pd.DataFrame(data) - - fig, ax = plt.subplots(figsize=self.fig_size) - - # Try to create a meaningful visualization - numeric_columns = df.select_dtypes(include=['number']).columns - - if len(numeric_columns) >= 2: - # Scatter plot for two numeric columns - x_col, y_col = numeric_columns[0], numeric_columns[1] - ax.scatter(df[x_col], df[y_col], alpha=0.6) - ax.set_xlabel(x_col) - ax.set_ylabel(y_col) - ax.set_title(f"{y_col} vs {x_col}") - elif len(numeric_columns) == 1: - # Line plot for single numeric column - col = numeric_columns[0] - ax.plot(df.index, df[col], marker='o') - ax.set_xlabel("Index") - ax.set_ylabel(col) - ax.set_title(f"{col} Trend") - else: - # Count plot for categorical data - first_col = df.columns[0] - value_counts = df[first_col].value_counts() - ax.bar(value_counts.index, value_counts.values) - ax.set_xlabel(first_col) - ax.set_ylabel("Count") - ax.set_title(f"{first_col} Distribution") - plt.xticks(rotation=45, ha='right') - - plt.tight_layout() - return self._fig_to_base64() - - def _create_table_chart(self, data: dict) -> str: - """Create table visualization.""" - fig, ax = plt.subplots(figsize=self.fig_size) - ax.axis('tight') - ax.axis('off') - - # Convert dict to table data - table_data = [[str(k), str(v)] for k, v in data.items()] - - table = ax.table( - cellText=table_data, - colLabels=['Key', 'Value'], - cellLoc='left', - loc='center' - ) - table.auto_set_font_size(False) - table.set_fontsize(10) - table.scale(1.2, 1.5) - - ax.set_title("Data Table") - - plt.tight_layout() - return self._fig_to_base64() - - def _create_text_chart(self, text: str) -> str: - """Create text-based chart.""" - fig, ax = plt.subplots(figsize=self.fig_size) - ax.text(0.5, 0.5, text, ha='center', va='center', - transform=ax.transAxes, fontsize=12, - bbox=dict(boxstyle="round,pad=0.3", facecolor="lightgray")) - ax.axis('off') - ax.set_title("Text Output") - - plt.tight_layout() - return self._fig_to_base64() - - def _fig_to_base64(self) -> str: - """Convert matplotlib figure to base64 string.""" - buffer = BytesIO() - plt.savefig(buffer, format='png', dpi=150, bbox_inches='tight') - buffer.seek(0) - - image_base64 = base64.b64encode(buffer.getvalue()).decode() - plt.close() - - return f"data:image/png;base64,{image_base64}" - -# Usage example -def visualization_example(): - """Example of data visualization.""" - visualizer = McpVisualizer() - - # Sample data from MCP tool results - sample_data = [ - {"month": "Jan", "sales": 1200, "profit": 200}, - {"month": "Feb", "sales": 1500, "profit": 300}, - {"month": "Mar", "sales": 1100, "profit": 150}, - {"month": "Apr", "sales": 1800, "profit": 400}, - {"month": "May", "sales": 2000, "profit": 500} - ] - - # Create visualization - chart_data_uri = visualizer.visualize_data(sample_data) - - # In a web context, you could embed this as: - # Data Visualization - - print(f"Chart created: {len(chart_data_uri)} characters") - - # Simple categorical data - categorical_data = {"Product A": 45, "Product B": 32, "Product C": 23} - pie_chart = visualizer.visualize_data(categorical_data, "pie") - print(f"Pie chart created: {len(pie_chart)} characters") - -if __name__ == "__main__": - visualization_example() -``` - -## Interactive components - -### Progress tracking - -```python -""" -Interactive progress tracking for long-running MCP operations. -""" - -import asyncio -import time -from typing import Callable, Any -from contextlib import asynccontextmanager - -class ProgressTracker: - """Track progress of MCP operations.""" - - def __init__(self, display_type: str = "rich"): - self.display_type = display_type - self.active_tasks = {} - - @asynccontextmanager - async def track_operation(self, description: str, total_steps: int = 100): - """Context manager for tracking operation progress.""" - task_id = id(asyncio.current_task()) - - if self.display_type == "rich": - from rich.progress import Progress, TaskID - progress = Progress() - progress.start() - progress_task = progress.add_task(description, total=total_steps) - else: - progress = SimpleProgress(description, total_steps) - progress_task = None - - self.active_tasks[task_id] = { - 'progress': progress, - 'task': progress_task, - 'current': 0, - 'total': total_steps - } - - try: - yield ProgressUpdater(self, task_id) - finally: - if self.display_type == "rich": - progress.stop() - else: - progress.finish() - del self.active_tasks[task_id] - - def update(self, task_id: int, advance: int = 1, message: str = None): - """Update progress for a task.""" - if task_id not in self.active_tasks: - return - - task_info = self.active_tasks[task_id] - task_info['current'] += advance - - if self.display_type == "rich": - progress = task_info['progress'] - progress_task = task_info['task'] - progress.update(progress_task, advance=advance, description=message) - else: - progress = task_info['progress'] - progress.update(task_info['current'], message) - -class ProgressUpdater: - """Helper class for updating progress.""" - - def __init__(self, tracker: ProgressTracker, task_id: int): - self.tracker = tracker - self.task_id = task_id - - def advance(self, steps: int = 1, message: str = None): - """Advance progress by specified steps.""" - self.tracker.update(self.task_id, steps, message) - - def set_message(self, message: str): - """Update progress message without advancing.""" - self.tracker.update(self.task_id, 0, message) - -class SimpleProgress: - """Simple text-based progress display.""" - - def __init__(self, description: str, total: int): - self.description = description - self.total = total - self.current = 0 - self.start_time = time.time() - print(f"Starting: {description}") - - def update(self, current: int, message: str = None): - """Update progress display.""" - self.current = current - percentage = (current / self.total) * 100 - elapsed = time.time() - self.start_time - - # Create simple progress bar - bar_length = 40 - filled_length = int(bar_length * current // self.total) - bar = '█' * filled_length + '░' * (bar_length - filled_length) - - status = f"\\r{self.description}: |{bar}| {percentage:.1f}% ({current}/{self.total})" - if message: - status += f" - {message}" - - print(status, end='', flush=True) - - def finish(self): - """Finish progress display.""" - elapsed = time.time() - self.start_time - print(f"\\nCompleted in {elapsed:.1f}s") - -# Usage example with MCP operations -async def progress_example(): - """Example of progress tracking with MCP operations.""" - tracker = ProgressTracker("simple") # or "rich" - - async with tracker.track_operation("Processing data", 100) as progress: - # Simulate MCP tool calls with progress updates - for i in range(10): - progress.set_message(f"Processing batch {i+1}/10") - - # Simulate tool call - await asyncio.sleep(0.2) - - # Update progress - progress.advance(10) - - progress.set_message("Finalizing results") - await asyncio.sleep(0.1) - -if __name__ == "__main__": - asyncio.run(progress_example()) -``` - -### Interactive menus - -```python -""" -Interactive menu system for MCP client applications. -""" - -import asyncio -from typing import List, Callable, Any, Optional - -class MenuItem: - """Represents a menu item.""" - - def __init__( - self, - key: str, - label: str, - action: Callable, - description: str = "" - ): - self.key = key - self.label = label - self.action = action - self.description = description - -class InteractiveMenu: - """Interactive menu for MCP client operations.""" - - def __init__(self, title: str = "MCP Client Menu"): - self.title = title - self.items: List[MenuItem] = [] - self.running = True - - def add_item(self, key: str, label: str, action: Callable, description: str = ""): - """Add a menu item.""" - self.items.append(MenuItem(key, label, action, description)) - - def add_separator(self): - """Add a menu separator.""" - self.items.append(MenuItem("", "---", None, "")) - - async def show(self): - """Display and run the interactive menu.""" - while self.running: - self._display_menu() - choice = await self._get_user_input() - await self._handle_choice(choice) - - def _display_menu(self): - """Display the menu options.""" - print("\\n" + "=" * 60) - print(f" {self.title}") - print("=" * 60) - - for item in self.items: - if item.key == "": - print(f" {item.label}") - else: - print(f" [{item.key}] {item.label}") - if item.description: - print(f" {item.description}") - - print("\\n [q] Quit") - print("=" * 60) - - async def _get_user_input(self) -> str: - """Get user input asynchronously.""" - # In a real application, you might use aioconsole for async input - import sys - try: - return input("Select option: ").strip().lower() - except (EOFError, KeyboardInterrupt): - return "q" - - async def _handle_choice(self, choice: str): - """Handle user menu choice.""" - if choice == "q": - self.running = False - print("Goodbye!") - return - - # Find matching menu item - for item in self.items: - if item.key == choice and item.action: - try: - if asyncio.iscoroutinefunction(item.action): - await item.action() - else: - item.action() - except Exception as e: - print(f"Error executing {item.label}: {e}") - return - - print(f"Invalid option: {choice}") - -# Example MCP client with interactive menu -class McpClientMenu: - """Interactive MCP client with menu interface.""" - - def __init__(self): - self.session: Optional[ClientSession] = None - self.connected = False - self.menu = InteractiveMenu("MCP Client") - self._setup_menu() - - def _setup_menu(self): - """Setup menu items.""" - self.menu.add_item("c", "Connect to Server", self._connect_server, - "Connect to an MCP server") - self.menu.add_item("d", "Disconnect", self._disconnect_server, - "Disconnect from current server") - self.menu.add_separator() - self.menu.add_item("t", "List Tools", self._list_tools, - "Show available tools") - self.menu.add_item("r", "List Resources", self._list_resources, - "Show available resources") - self.menu.add_item("p", "List Prompts", self._list_prompts, - "Show available prompts") - self.menu.add_separator() - self.menu.add_item("x", "Execute Tool", self._execute_tool, - "Call a tool with parameters") - self.menu.add_item("g", "Get Resource", self._get_resource, - "Read a resource") - self.menu.add_item("m", "Get Prompt", self._get_prompt, - "Get a prompt template") - self.menu.add_separator() - self.menu.add_item("s", "Server Status", self._server_status, - "Show server information") - - async def run(self): - """Run the interactive client.""" - print("Welcome to the MCP Interactive Client!") - await self.menu.show() - - async def _connect_server(self): - """Connect to MCP server.""" - if self.connected: - print("Already connected to a server. Disconnect first.") - return - - server_url = input("Enter server URL (http://localhost:8000/mcp): ").strip() - if not server_url: - server_url = "http://localhost:8000/mcp" - - try: - print(f"Connecting to {server_url}...") - - # This would use the actual MCP client - # async with streamablehttp_client(server_url) as (read, write, _): - # self.session = ClientSession(read, write) - # await self.session.__aenter__() - # await self.session.initialize() - - # Mock connection for demo - await asyncio.sleep(1) - self.connected = True - print("✓ Connected successfully!") - - except Exception as e: - print(f"✗ Connection failed: {e}") - - async def _disconnect_server(self): - """Disconnect from server.""" - if not self.connected: - print("Not connected to any server.") - return - - try: - # if self.session: - # await self.session.__aexit__(None, None, None) - # self.session = None - - # Mock disconnection - await asyncio.sleep(0.5) - self.connected = False - print("✓ Disconnected successfully!") - - except Exception as e: - print(f"✗ Disconnection failed: {e}") - - async def _list_tools(self): - """List available tools.""" - if not self.connected: - print("Not connected to server.") - return - - print("Fetching tools...") - - # Mock tool list - tools = [ - {"name": "calculate", "description": "Perform calculations"}, - {"name": "format_text", "description": "Format text strings"}, - {"name": "get_weather", "description": "Get weather information"} - ] - - print("\\nAvailable Tools:") - for i, tool in enumerate(tools, 1): - print(f" {i}. {tool['name']} - {tool['description']}") - - async def _list_resources(self): - """List available resources.""" - if not self.connected: - print("Not connected to server.") - return - - print("Fetching resources...") - - # Mock resource list - resources = [ - {"uri": "config://settings", "name": "Server Settings"}, - {"uri": "data://users", "name": "User Database"}, - {"uri": "logs://recent", "name": "Recent Logs"} - ] - - print("\\nAvailable Resources:") - for i, resource in enumerate(resources, 1): - print(f" {i}. {resource['name']} ({resource['uri']})") - - async def _list_prompts(self): - """List available prompts.""" - if not self.connected: - print("Not connected to server.") - return - - print("Fetching prompts...") - - # Mock prompt list - prompts = [ - {"name": "analyze_data", "description": "Data analysis prompt"}, - {"name": "code_review", "description": "Code review prompt"}, - {"name": "summarize", "description": "Text summarization prompt"} - ] - - print("\\nAvailable Prompts:") - for i, prompt in enumerate(prompts, 1): - print(f" {i}. {prompt['name']} - {prompt['description']}") - - async def _execute_tool(self): - """Execute a tool.""" - if not self.connected: - print("Not connected to server.") - return - - tool_name = input("Enter tool name: ").strip() - if not tool_name: - print("Tool name required.") - return - - print(f"Enter parameters for {tool_name} (JSON format):") - params_str = input("Parameters: ").strip() - - try: - import json - params = json.loads(params_str) if params_str else {} - except json.JSONDecodeError: - print("Invalid JSON parameters.") - return - - print(f"Executing {tool_name} with parameters: {params}") - - # Mock tool execution - await asyncio.sleep(1) - result = f"Tool {tool_name} executed successfully with result: 42" - print(f"Result: {result}") - - async def _get_resource(self): - """Get a resource.""" - if not self.connected: - print("Not connected to server.") - return - - uri = input("Enter resource URI: ").strip() - if not uri: - print("Resource URI required.") - return - - print(f"Fetching resource: {uri}") - - # Mock resource fetch - await asyncio.sleep(0.5) - content = f"Content of resource {uri}: This is sample resource data." - print(f"Resource content: {content}") - - async def _get_prompt(self): - """Get a prompt.""" - if not self.connected: - print("Not connected to server.") - return - - prompt_name = input("Enter prompt name: ").strip() - if not prompt_name: - print("Prompt name required.") - return - - print(f"Fetching prompt: {prompt_name}") - - # Mock prompt fetch - await asyncio.sleep(0.5) - prompt_text = f"Prompt template for {prompt_name}: Please analyze the following data..." - print(f"Prompt: {prompt_text}") - - async def _server_status(self): - """Show server status.""" - if not self.connected: - print("Not connected to server.") - return - - print("Server Status:") - print(f" Connected: {'Yes' if self.connected else 'No'}") - print(" Server: Example MCP Server v1.0.0") - print(" Protocol: 2025-06-18") - print(" Uptime: 2 hours") - -# Usage example -async def interactive_menu_example(): - """Run the interactive MCP client menu.""" - client = McpClientMenu() - await client.run() - -if __name__ == "__main__": - asyncio.run(interactive_menu_example()) -``` - -## Web interface utilities - -### HTML generation - -```python -""" -HTML generation utilities for web-based MCP clients. -""" - -from typing import Any, Dict, List -import json -import html - -class HtmlGenerator: - """Generate HTML for MCP client web interfaces.""" - - def __init__(self, theme: str = "light"): - self.theme = theme - self.styles = self._get_styles() - - def _get_styles(self) -> str: - """Get CSS styles for the theme.""" - if self.theme == "dark": - return """ - - """ - else: - return """ - - """ - - def generate_page(self, title: str, content: str) -> str: - """Generate complete HTML page.""" - return f""" - - - - - - {html.escape(title)} - {self.styles} - - -
-

{html.escape(title)}

- {content} -
- - - """ - - def format_server_info(self, server_info: dict) -> str: - """Format server information as HTML.""" - name = html.escape(server_info.get('name', 'Unknown')) - version = html.escape(server_info.get('version', 'Unknown')) - protocol = html.escape(server_info.get('protocolVersion', 'Unknown')) - - return f""" -
-

Server Information

- - - - -
Name{name}
Version{version}
Protocol{protocol}
-
- """ - - def format_tools_list(self, tools: list) -> str: - """Format tools list as HTML.""" - if not tools: - return '

No tools available

' - - rows = "" - for tool in tools: - name = html.escape(tool.get('name', '')) - description = html.escape(tool.get('description', 'No description')) - schema = self._format_schema_html(tool.get('inputSchema', {})) - - rows += f""" - - {name} - {description} - {schema} - - """ - - return f""" -
-

Available Tools

- - - - - - - - - - {rows} - -
NameDescriptionParameters
-
- """ - - def format_tool_result(self, tool_name: str, result: dict) -> str: - """Format tool result as HTML.""" - name = html.escape(tool_name) - success = result.get('success', True) - status_class = "success" if success else "error" - status_text = "✓ Success" if success else "✗ Error" - - content_html = "" - if 'content' in result: - for item in result['content']: - if isinstance(item, str): - content_html += f"

{html.escape(item)}

" - else: - content_html += f"
{html.escape(json.dumps(item, indent=2))}
" - - structured_html = "" - if 'structured' in result and result['structured']: - structured_html = f""" -

Structured Output:

-
{html.escape(json.dumps(result['structured'], indent=2))}
- """ - - error_html = "" - if not success and 'error' in result: - error_html = f'

Error: {html.escape(result["error"])}

' - - return f""" -
-

Tool Result: {name} {status_text}

- {content_html} - {structured_html} - {error_html} -
- """ - - def _format_schema_html(self, schema: dict) -> str: - """Format input schema as HTML.""" - if not schema or 'properties' not in schema: - return "No parameters" - - props = schema['properties'] - required = schema.get('required', []) - - param_list = [] - for prop_name, prop_info in props.items(): - prop_type = prop_info.get('type', 'any') - is_required = prop_name in required - - param_text = f"{prop_name}: {prop_type}" - if is_required: - param_text = f"{param_text}" - - param_list.append(param_text) - - return ", ".join(param_list) - -# Usage example -def html_example(): - """Example of HTML generation.""" - generator = HtmlGenerator("light") - - # Sample data - server_info = { - "name": "Example MCP Server", - "version": "1.0.0", - "protocolVersion": "2025-06-18" - } - - tools = [ - { - "name": "calculate", - "description": "Perform mathematical calculations", - "inputSchema": { - "properties": { - "expression": {"type": "string"}, - "precision": {"type": "integer"} - }, - "required": ["expression"] - } - } - ] - - # Generate HTML components - server_html = generator.format_server_info(server_info) - tools_html = generator.format_tools_list(tools) - - # Combine into full page - content = server_html + tools_html - page = generator.generate_page("MCP Client Dashboard", content) - - # Save to file - with open("mcp_dashboard.html", "w") as f: - f.write(page) - - print("HTML dashboard saved to mcp_dashboard.html") - -if __name__ == "__main__": - html_example() -``` - -## Best practices - -### Design guidelines - -- **Consistent interface** - Use consistent styling and interaction patterns -- **Clear feedback** - Provide immediate feedback for all user actions -- **Error handling** - Display helpful error messages with recovery suggestions -- **Accessibility** - Support keyboard navigation and screen readers -- **Responsive design** - Work well on different screen sizes - -### Performance optimization - -- **Lazy loading** - Load visualization data only when needed -- **Caching** - Cache formatted output to avoid recomputation -- **Async operations** - Keep UI responsive during long operations -- **Memory management** - Clean up resources after use -- **Efficient rendering** - Minimize DOM updates and redraws - -### User experience - -- **Progressive disclosure** - Show basic info first, details on demand -- **Contextual help** - Provide help text and examples -- **Keyboard shortcuts** - Support common keyboard shortcuts -- **Search and filter** - Help users find relevant information -- **State persistence** - Remember user preferences and settings - -## Next steps - -- **[Parsing results](parsing-results.md)** - Advanced result processing -- **[OAuth for clients](oauth-clients.md)** - Authentication in client UIs -- **[Writing clients](writing-clients.md)** - Complete client development guide -- **[Low-level server](low-level-server.md)** - Server implementation details \ No newline at end of file diff --git a/docs/elicitation.md b/docs/elicitation.md deleted file mode 100644 index 2df7d7949..000000000 --- a/docs/elicitation.md +++ /dev/null @@ -1,442 +0,0 @@ -# Elicitation - -Elicitation allows servers to request additional information from users with structured validation. This enables interactive workflows where tools can gather missing data before proceeding. - -## Basic elicitation - -### Simple user input collection - -```python -from pydantic import BaseModel, Field -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - -mcp = FastMCP("Interactive Server") - -class UserInfo(BaseModel): - """Schema for collecting user information.""" - name: str = Field(description="Your full name") - email: str = Field(description="Your email address") - age: int = Field(description="Your age", ge=13, le=120) - -@mcp.tool() -async def collect_user_info(ctx: Context[ServerSession, None]) -> dict: - """Collect user information through elicitation.""" - result = await ctx.elicit( - message="Please provide your information to continue:", - schema=UserInfo - ) - - if result.action == "accept" and result.data: - return { - "status": "collected", - "name": result.data.name, - "email": result.data.email, - "age": result.data.age - } - elif result.action == "decline": - return {"status": "declined"} - else: - return {"status": "cancelled"} -``` - -### Conditional elicitation - -```python -class BookingPreferences(BaseModel): - """Schema for restaurant booking preferences.""" - alternative_date: str = Field(description="Alternative date (YYYY-MM-DD)") - party_size: int = Field(description="Number of people", ge=1, le=20) - dietary_restrictions: str = Field(default="", description="Any dietary restrictions") - -@mcp.tool() -async def book_restaurant( - restaurant: str, - preferred_date: str, - ctx: Context[ServerSession, None] -) -> dict: - """Book restaurant with fallback options.""" - - # Simulate availability check - if preferred_date in ["2024-12-25", "2024-12-31"]: # Busy dates - await ctx.warning(f"No availability at {restaurant} on {preferred_date}") - - result = await ctx.elicit( - message=f"Sorry, {restaurant} is fully booked on {preferred_date}. Would you like to try another date?", - schema=BookingPreferences - ) - - if result.action == "accept" and result.data: - booking = result.data - await ctx.info(f"Alternative booking confirmed for {booking.alternative_date}") - - return { - "status": "booked", - "restaurant": restaurant, - "date": booking.alternative_date, - "party_size": booking.party_size, - "dietary_restrictions": booking.dietary_restrictions, - "confirmation_id": f"BK{hash(booking.alternative_date) % 10000:04d}" - } - else: - return {"status": "cancelled", "reason": "No alternative date provided"} - else: - # Direct booking for available dates - return { - "status": "booked", - "restaurant": restaurant, - "date": preferred_date, - "confirmation_id": f"BK{hash(preferred_date) % 10000:04d}" - } -``` - -## Advanced elicitation patterns - -### Multi-step workflows - -```python -class ProjectDetails(BaseModel): - """Initial project information.""" - name: str = Field(description="Project name") - type: str = Field(description="Project type (web, mobile, desktop, api)") - timeline: str = Field(description="Expected timeline") - -class TechnicalRequirements(BaseModel): - """Technical requirements based on project type.""" - framework: str = Field(description="Preferred framework") - database: str = Field(description="Database type") - hosting: str = Field(description="Hosting preference") - team_size: int = Field(description="Team size", ge=1, le=50) - -@mcp.tool() -async def create_project_plan(ctx: Context[ServerSession, None]) -> dict: - """Create project plan through multi-step elicitation.""" - - # Step 1: Collect basic project details - await ctx.info("Starting project planning wizard...") - - project_result = await ctx.elicit( - message="Let's start by gathering basic project information:", - schema=ProjectDetails - ) - - if project_result.action != "accept" or not project_result.data: - return {"status": "cancelled", "step": "project_details"} - - project = project_result.data - await ctx.info(f"Project '{project.name}' details collected") - - # Step 2: Collect technical requirements - tech_result = await ctx.elicit( - message=f"Now let's configure technical requirements for your {project.type} project:", - schema=TechnicalRequirements - ) - - if tech_result.action != "accept" or not tech_result.data: - return { - "status": "partial", - "project_details": project.dict(), - "cancelled_at": "technical_requirements" - } - - tech = tech_result.data - await ctx.info("Technical requirements collected") - - # Generate project plan - plan = { - "project": { - "name": project.name, - "type": project.type, - "timeline": project.timeline - }, - "technical": { - "framework": tech.framework, - "database": tech.database, - "hosting": tech.hosting, - "team_size": tech.team_size - }, - "next_steps": [ - "Set up development environment", - "Create project repository", - "Define development workflow", - "Plan sprint structure" - ], - "status": "complete" - } - - await ctx.info(f"Project plan created for '{project.name}'") - return plan -``` - -### Dynamic schema generation - -```python -from typing import Any, Dict - -def create_survey_schema(questions: list[dict]) -> type[BaseModel]: - """Dynamically create a Pydantic model for survey questions.""" - fields = {} - - for i, question in enumerate(questions): - field_name = f"question_{i+1}" - field_type = str - - if question["type"] == "number": - field_type = int - elif question["type"] == "boolean": - field_type = bool - - fields[field_name] = (field_type, Field(description=question["text"])) - - return type("DynamicSurvey", (BaseModel,), {"__annotations__": {k: v[0] for k, v in fields.items()}, **{k: v[1] for k, v in fields.items()}}) - -@mcp.tool() -async def conduct_survey( - survey_title: str, - questions: list[dict], - ctx: Context[ServerSession, None] -) -> dict: - """Conduct dynamic survey using elicitation.""" - - if not questions: - raise ValueError("At least one question is required") - - # Create dynamic schema - SurveySchema = create_survey_schema(questions) - - await ctx.info(f"Starting survey: {survey_title}") - - result = await ctx.elicit( - message=f"Please complete this survey: {survey_title}", - schema=SurveySchema - ) - - if result.action == "accept" and result.data: - # Process responses - responses = {} - for i, question in enumerate(questions): - field_name = f"question_{i+1}" - responses[question["text"]] = getattr(result.data, field_name) - - await ctx.info(f"Survey completed with {len(responses)} responses") - - return { - "survey_title": survey_title, - "status": "completed", - "responses": responses, - "response_count": len(responses) - } - - return {"status": "not_completed", "reason": result.action} -``` - -## Error handling and validation - -### Robust elicitation with retries - -```python -class ContactInfo(BaseModel): - """Contact information with validation.""" - email: str = Field(description="Email address", regex=r'^[^@]+@[^@]+\.[^@]+$') - phone: str = Field(description="Phone number", regex=r'^[\d\s\-\(\)\+]+$') - preferred_contact: str = Field(description="Preferred contact method (email/phone)") - -@mcp.tool() -async def collect_contact_info( - ctx: Context[ServerSession, None], - max_attempts: int = 3 -) -> dict: - """Collect contact info with validation and retries.""" - - for attempt in range(max_attempts): - await ctx.info(f"Contact info collection attempt {attempt + 1}/{max_attempts}") - - result = await ctx.elicit( - message="Please provide your contact information:", - schema=ContactInfo - ) - - if result.action == "accept" and result.data: - # Additional validation - contact = result.data - - if contact.preferred_contact not in ["email", "phone"]: - if attempt < max_attempts - 1: - await ctx.warning("Invalid preferred contact method. Please choose 'email' or 'phone'.") - continue - else: - return { - "status": "error", - "error": "Invalid preferred contact method after max attempts" - } - - await ctx.info("Contact information validated successfully") - - return { - "status": "success", - "contact_info": { - "email": contact.email, - "phone": contact.phone, - "preferred_contact": contact.preferred_contact - }, - "attempts_used": attempt + 1 - } - - elif result.action == "decline": - return {"status": "declined", "attempts_used": attempt + 1} - - else: # cancelled - if attempt < max_attempts - 1: - await ctx.info("Input cancelled, retrying...") - else: - return {"status": "cancelled", "attempts_used": max_attempts} - - return {"status": "max_attempts_exceeded", "attempts_used": max_attempts} -``` - -### Validation error handling - -```python -from pydantic import ValidationError - -class OrderInfo(BaseModel): - """Order information with strict validation.""" - item_id: str = Field(description="Product ID", min_length=3, max_length=10) - quantity: int = Field(description="Quantity to order", ge=1, le=100) - shipping_address: str = Field(description="Shipping address", min_length=10) - express_shipping: bool = Field(description="Express shipping?", default=False) - -@mcp.tool() -async def process_order(ctx: Context[ServerSession, None]) -> dict: - """Process order with detailed validation feedback.""" - - while True: # Continue until valid or cancelled - result = await ctx.elicit( - message="Please provide order details:", - schema=OrderInfo - ) - - if result.action == "accept": - if result.data: - order = result.data - - # Additional business logic validation - validation_errors = [] - - # Check if item exists (simulated) - valid_items = ["ITEM001", "ITEM002", "ITEM003"] - if order.item_id not in valid_items: - validation_errors.append(f"Item ID '{order.item_id}' not found") - - # Check quantity limits based on item (simulated) - if order.item_id == "ITEM001" and order.quantity > 10: - validation_errors.append("Maximum 10 units allowed for ITEM001") - - if validation_errors: - error_message = "Validation errors found:\n" + "\n".join(f"- {error}" for error in validation_errors) - await ctx.warning(error_message) - await ctx.info("Please correct the errors and try again") - continue # Retry elicitation - - # Process successful order - await ctx.info(f"Order processed for item {order.item_id}") - - return { - "status": "processed", - "order_id": f"ORD{hash(order.item_id + str(order.quantity)) % 10000:04d}", - "item_id": order.item_id, - "quantity": order.quantity, - "express_shipping": order.express_shipping, - "estimated_delivery": "3-5 days" if not order.express_shipping else "1-2 days" - } - else: - await ctx.warning("No order data received") - continue - - elif result.action == "decline": - return {"status": "declined"} - - else: # cancelled - return {"status": "cancelled"} -``` - -## Testing elicitation - -### Unit testing with mocks - -```python -import pytest -from unittest.mock import Mock, AsyncMock -from mcp.types import ElicitationResult - -@pytest.mark.asyncio -async def test_elicitation_accept(): - """Test successful elicitation.""" - - # Mock elicitation result - mock_data = UserInfo(name="Test User", email="test@example.com", age=25) - mock_result = ElicitationResult( - action="accept", - data=mock_data, - validation_error=None - ) - - # Mock context - mock_ctx = Mock() - mock_ctx.elicit = AsyncMock(return_value=mock_result) - - # Test function - result = await collect_user_info(mock_ctx) - - assert result["status"] == "collected" - assert result["name"] == "Test User" - assert result["email"] == "test@example.com" - mock_ctx.elicit.assert_called_once() - -@pytest.mark.asyncio -async def test_elicitation_decline(): - """Test declined elicitation.""" - - mock_result = ElicitationResult( - action="decline", - data=None, - validation_error=None - ) - - mock_ctx = Mock() - mock_ctx.elicit = AsyncMock(return_value=mock_result) - - result = await collect_user_info(mock_ctx) - - assert result["status"] == "declined" -``` - -## Best practices - -### Design guidelines - -- **Clear messaging** - Provide clear, specific instructions in elicitation messages -- **Progressive complexity** - Start with simple requests, build up complexity -- **Graceful degradation** - Handle cancellation and errors appropriately -- **Validation feedback** - Give users clear feedback on validation errors - -### User experience - -- **Reasonable defaults** - Provide sensible default values where appropriate -- **Context awareness** - Reference previous inputs in multi-step workflows -- **Progress indication** - Show users where they are in multi-step processes -- **Escape routes** - Always provide ways to cancel or go back - -### Performance considerations - -- **Timeout handling** - Set reasonable timeouts for user input -- **State management** - Clean up incomplete elicitation state -- **Error recovery** - Implement retry logic for network issues -- **Resource cleanup** - Free resources for abandoned elicitations - -## Next steps - -- **[Sampling integration](sampling.md)** - Use elicitation with LLM sampling -- **[Progress reporting](progress-logging.md)** - Show progress during elicitation -- **[Context patterns](context.md)** - Advanced context usage in elicitation -- **[Authentication](authentication.md)** - Securing elicitation endpoints \ No newline at end of file diff --git a/docs/examples-authentication.md b/docs/examples-authentication.md new file mode 100644 index 000000000..dae0d5ed2 --- /dev/null +++ b/docs/examples-authentication.md @@ -0,0 +1,146 @@ +# Authentication examples + +MCP supports OAuth 2.1 authentication for protecting server resources. This section demonstrates both server-side token verification and client-side authentication flows. + +## OAuth server implementation + +FastMCP server with OAuth token verification: + +```python +--8<-- "examples/snippets/servers/oauth_server.py" +``` + +This example shows: + +- Implementing the `TokenVerifier` protocol for token validation +- Using `AuthSettings` for RFC 9728 Protected Resource Metadata +- Resource server configuration with authorization server discovery +- Protected tools that require authentication + +## Complete authentication server + +Full Authorization Server implementation with token introspection: + +```python +--8<-- "examples/servers/simple-auth/mcp_simple_auth/auth_server.py" +``` + +This comprehensive example includes: + +- OAuth 2.1 authorization flows (authorization code, refresh token) +- Token introspection endpoint for resource servers +- Client registration and metadata management +- RFC 9728 protected resource metadata endpoint + +## Resource server with introspection + +MCP Resource Server that validates tokens via Authorization Server introspection: + +```python +--8<-- "examples/servers/simple-auth/mcp_simple_auth/server.py" +``` + +This demonstrates: + +- Token introspection for validation instead of local token verification +- Separation of Authorization Server (AS) and Resource Server (RS) +- Protected MCP tools and resources +- Production-ready server patterns + +## Token verification implementation + +Custom token verification logic: + +```python +--8<-- "examples/servers/simple-auth/mcp_simple_auth/token_verifier.py" +``` + +This component handles: + +- HTTP token introspection requests +- Token validation with scope checking +- RFC 8707 resource parameter validation +- Error handling and logging + +## Simple authentication provider + +Authentication provider for development and testing: + +```python +--8<-- "examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py" +``` + +This utility provides: + +- Simplified token generation for testing +- Development authentication flows +- Testing utilities for protected resources + +## Legacy Authorization Server + +Backward compatibility with older OAuth implementations: + +```python +--8<-- "examples/servers/simple-auth/mcp_simple_auth/legacy_as_server.py" +``` + +This example shows: + +- Support for non-RFC 9728 compliant clients +- Legacy endpoint compatibility +- Migration patterns for existing systems + +## OAuth architecture + +The MCP OAuth implementation follows the OAuth 2.1 authorization code flow with token introspection: + +```mermaid +sequenceDiagram + participant C as Client + participant AS as Authorization Server + participant RS as Resource Server
(MCP Server) + participant U as User + + Note over C,RS: 1. Discovery Phase (RFC 9728) + C->>RS: GET /.well-known/oauth-protected-resource + RS->>C: Protected Resource Metadata
(issuer, scopes, etc.) + + Note over C,AS: 2. Authorization Phase + C->>AS: GET /authorize?response_type=code&client_id=... + AS->>U: Redirect to login/consent + U->>AS: User authenticates and consents + AS->>C: Authorization code (via redirect) + + Note over C,AS: 3. Token Exchange + C->>AS: POST /token
(authorization_code grant) + AS->>C: Access token + refresh token + + Note over C,RS: 4. Resource Access + C->>RS: MCP request + Authorization: Bearer + RS->>AS: POST /introspect
(validate token) + AS->>RS: Token info (active, scopes, user) + RS->>C: MCP response (if authorized) + + Note over C,AS: 5. Token Refresh (when needed) + C->>AS: POST /token
(refresh_token grant) + AS->>C: New access token +``` + +**Components:** + +- **Authorization Server (AS)**: Handles OAuth flows, issues and validates tokens +- **Resource Server (RS)**: Your MCP server that validates tokens and serves protected resources +- **Client**: Discovers AS through RFC 9728, obtains tokens, and uses them with MCP server +- **User**: Resource owner who authorizes access + +## Security considerations + +When implementing authentication: + +1. **Use HTTPS**: All OAuth flows must use HTTPS in production +2. **Token validation**: Always validate tokens on the resource server side +3. **Scope checking**: Verify that tokens have required scopes +4. **Introspection**: Use token introspection for distributed validation +5. **RFC compliance**: Follow RFC 9728 for proper AS discovery + +These examples provide a complete OAuth 2.1 implementation suitable for production use with proper security practices. \ No newline at end of file diff --git a/docs/examples-clients.md b/docs/examples-clients.md new file mode 100644 index 000000000..d59b31818 --- /dev/null +++ b/docs/examples-clients.md @@ -0,0 +1,127 @@ +# Client examples + +MCP clients connect to servers to access tools, resources, and prompts. This section demonstrates various client patterns and connection types. + +## Basic stdio client + +Connecting to MCP servers over stdio transport: + +```python +--8<-- "examples/snippets/clients/stdio_client.py" +``` + +This fundamental example demonstrates: + +- Creating `StdioServerParameters` for server connection +- Using `ClientSession` for MCP communication +- Listing and calling tools, reading resources, getting prompts +- Handling both structured and unstructured tool results +- Sampling callback implementation for LLM integration + +## Streamable HTTP client + +Connecting to HTTP-based MCP servers: + +```python +--8<-- "examples/snippets/clients/streamable_basic.py" +``` + +This example shows: + +- Using `streamablehttp_client` for HTTP connections +- Simpler connection setup for web-deployed servers +- Basic tool listing and execution over HTTP + +## Display utilities + +Helper utilities for client user interfaces: + +```python +--8<-- "examples/snippets/clients/display_utilities.py" +``` + +This practical example covers: + +- Using `get_display_name()` for human-readable names +- Proper precedence rules for tool/resource titles +- Building user-friendly client interfaces +- Consistent naming across different MCP objects + +## OAuth authentication client + +Client-side OAuth 2.1 authentication flow: + +```python +--8<-- "examples/snippets/clients/oauth_client.py" +``` + +This comprehensive example demonstrates: + +- `OAuthClientProvider` setup and configuration +- Token storage with custom `TokenStorage` implementation +- Authorization flow handling (redirect and callback) +- Authenticated requests to protected MCP servers + +## Completion client + +Using completion suggestions for better user experience: + +```python +--8<-- "examples/snippets/clients/completion_client.py" +``` + +This advanced example shows: + +- Resource template argument completion +- Context-aware completions (e.g., repository suggestions based on owner) +- Prompt argument completion +- Dynamic suggestion generation + +## Tool result parsing + +Understanding and processing tool results: + +```python +--8<-- "examples/snippets/clients/parsing_tool_results.py" +``` + +This detailed example covers: + +- Parsing different content types (`TextContent`, `ImageContent`, `EmbeddedResource`) +- Handling structured output data +- Processing embedded resources +- Error handling for failed tool executions + +## Complete chatbot client + +A full-featured chatbot that integrates with multiple MCP servers: + +```python +--8<-- "examples/clients/simple-chatbot/mcp_simple_chatbot/main.py" +``` + +This production-ready example includes: + +- **Multi-server management**: Connect to multiple MCP servers simultaneously +- **LLM integration**: Use Groq API for natural language processing +- **Tool orchestration**: Automatic tool selection and execution +- **Error handling**: Retry mechanisms and graceful failure handling +- **Configuration management**: JSON-based server configuration +- **Session management**: Persistent conversation context + +## Authentication client + +Complete OAuth client implementation: + +```python +--8<-- "examples/clients/simple-auth-client/mcp_simple_auth_client/main.py" +``` + +This example demonstrates: + +- Full OAuth 2.1 client implementation +- Token management and refresh +- Protected resource access +- Integration with authenticated MCP servers + +These examples provide comprehensive patterns for building MCP clients that can handle various server types, authentication methods, and interaction patterns. \ No newline at end of file diff --git a/docs/examples-echo-servers.md b/docs/examples-echo-servers.md new file mode 100644 index 000000000..1d1b89bb9 --- /dev/null +++ b/docs/examples-echo-servers.md @@ -0,0 +1,75 @@ +# Echo server examples + +Echo servers are simple examples that demonstrate basic MCP functionality by echoing input back to clients. These are useful for testing and understanding MCP fundamentals. + +## Simple echo server + +The most basic echo implementation: + +```python +--8<-- "examples/fastmcp/simple_echo.py" +``` + +This minimal example shows: + +- Single tool implementation with string input/output +- Basic parameter handling +- Simple string manipulation and return + +## Enhanced echo server + +More sophisticated echo patterns: + +```python +--8<-- "examples/fastmcp/echo.py" +``` + +This enhanced version demonstrates: + +- Multiple echo variants (basic echo, uppercase, reverse) +- Different parameter types and patterns +- Tool naming and description best practices + +Echo servers are useful for: + +- **Testing client connections**: Verify that your client can connect and call tools +- **Understanding MCP basics**: Learn the fundamental request/response patterns +- **Development and debugging**: Simple, predictable behavior for testing +- **Protocol verification**: Ensure transport layers work correctly + +## Usage patterns + +These echo servers can be used to test different aspects of MCP: + +```bash +# Test with MCP Inspector +uv run mcp dev echo.py + +# Test direct execution +python echo.py + +# Test with custom clients +# (Use the client examples to connect to these echo servers) +``` + +## Testing tool calls + +Example tool calls you can make to echo servers: + +```json +{ + "tool": "echo", + "arguments": { + "message": "Hello, MCP!" + } +} +``` + +Expected response: +```json +{ + "result": "Echo: Hello, MCP!" +} +``` + +Echo servers provide a foundation for understanding MCP patterns before building more complex functionality. \ No newline at end of file diff --git a/docs/examples-lowlevel-servers.md b/docs/examples-lowlevel-servers.md new file mode 100644 index 000000000..0643246fc --- /dev/null +++ b/docs/examples-lowlevel-servers.md @@ -0,0 +1,95 @@ +# Low-level server examples + +The low-level server API provides maximum control over MCP protocol implementation. Use these patterns when you need fine-grained control or when FastMCP doesn't meet your requirements. + +## Basic low-level server + +Fundamental low-level server patterns: + +```python +--8<-- "examples/snippets/servers/lowlevel/basic.py" +``` + +This example demonstrates: + +- Creating a `Server` instance directly +- Manual handler registration with decorators +- Prompt management with `@server.list_prompts()` and `@server.get_prompt()` +- Manual capability declaration +- Explicit initialization and connection handling + +## Low-level server with lifespan + +Resource management and lifecycle control: + +```python +--8<-- "examples/snippets/servers/lowlevel/lifespan.py" +``` + +This advanced pattern shows: + +- Custom lifespan context manager for resource initialization +- Database connection management example +- Accessing lifespan context through `server.request_context` +- Tool implementation with resource access +- Proper cleanup and connection management + +## Structured output with low-level API + +Manual structured output control: + +```python +--8<-- "examples/snippets/servers/lowlevel/structured_output.py" +``` + +And a standalone implementation: + +```python +--8<-- "examples/servers/structured_output_lowlevel.py" +``` + +These examples cover: + +- Manual `outputSchema` definition in tool specifications +- Direct dictionary return for structured data +- Automatic validation against defined schemas +- Backward compatibility with text content + +## Simple tool server + +Complete low-level server focused on tools: + +```python +--8<-- "examples/servers/simple-tool/mcp_simple_tool/server.py" +``` + +This production-ready example includes: + +- Full tool lifecycle management +- Input validation and error handling +- Proper MCP protocol compliance +- Tool execution with structured responses + +## Key differences from FastMCP + +| Aspect | Low-level API | FastMCP | +|--------|---------------|---------| +| **Control** | Maximum control | Convention over configuration | +| **Boilerplate** | More verbose | Minimal setup | +| **Decorators** | Server method decorators | Simple function decorators | +| **Schema** | Manual definition | Automatic from type hints | +| **Lifecycle** | Manual management | Automatic handling | +| **Best for** | Complex custom logic | Rapid development | + +## When to use low-level API + +Choose the low-level API when you need: + +- Custom protocol message handling +- Complex initialization sequences +- Fine-grained control over capabilities +- Integration with existing server infrastructure +- Performance optimization at the protocol level +- Custom authentication or authorization logic + +The low-level API provides the foundation that FastMCP is built upon, giving you access to all MCP protocol features with complete control over implementation details. \ No newline at end of file diff --git a/docs/examples-quickstart.md b/docs/examples-quickstart.md new file mode 100644 index 000000000..28b7d1ab6 --- /dev/null +++ b/docs/examples-quickstart.md @@ -0,0 +1,52 @@ +# Getting started + +This section provides quick and simple examples to get you started with the MCP Python SDK. + +## FastMCP quickstart + +The simplest way to create an MCP server is with FastMCP. This example demonstrates the core concepts: tools, resources, and prompts. + +```python +--8<-- "examples/snippets/servers/fastmcp_quickstart.py" +``` + +This example shows how to: + +- Create a FastMCP server instance +- Add a tool that performs computation (`add`) +- Add a dynamic resource that provides data (`greeting://`) +- Add a prompt template for LLM interactions (`greet_user`) + +## Basic readme example + +An even simpler starting point: + +```python +--8<-- "examples/fastmcp/readme-quickstart.py" +``` + +## Direct execution + +For the simplest possible server deployment: + +```python +--8<-- "examples/snippets/servers/direct_execution.py" +``` + +This example demonstrates: + +- Minimal server setup with just a greeting tool +- Direct execution without additional configuration +- Entry point setup for standalone running + +All these examples can be run directly with: + +```bash +python server.py +``` + +Or tested with the MCP Inspector: + +```bash +uv run mcp dev server.py +``` \ No newline at end of file diff --git a/docs/examples-server-advanced.md b/docs/examples-server-advanced.md new file mode 100644 index 000000000..256892e3f --- /dev/null +++ b/docs/examples-server-advanced.md @@ -0,0 +1,95 @@ +# Advanced server examples + +This section covers advanced server patterns including lifecycle management, context handling, and interactive capabilities. + +## Lifespan management + +Managing server lifecycle with resource initialization and cleanup: + +```python +--8<-- "examples/snippets/servers/lifespan_example.py" +``` + +This example demonstrates: + +- Type-safe lifespan context management +- Resource initialization on startup (database connections, etc.) +- Automatic cleanup on shutdown +- Accessing lifespan context from tools via `ctx.request_context.lifespan_context` + +## User interaction and elicitation + +Tools that can request additional information from users: + +```python +--8<-- "examples/snippets/servers/elicitation.py" +``` + +This example shows: + +- Using `ctx.elicit()` to request user input +- Pydantic schemas for validating user responses +- Handling user acceptance, decline, or cancellation +- Interactive booking workflow patterns + +## LLM sampling and integration + +Tools that interact with LLMs through sampling: + +```python +--8<-- "examples/snippets/servers/sampling.py" +``` + +This demonstrates: + +- Using `ctx.session.create_message()` for LLM interaction +- Structured message creation with `SamplingMessage` and `TextContent` +- Processing LLM responses within tools +- Chaining LLM interactions for complex workflows + +## Logging and notifications + +Advanced logging and client notification patterns: + +```python +--8<-- "examples/snippets/servers/notifications.py" +``` + +This example covers: + +- Multiple log levels (debug, info, warning, error) +- Resource change notifications via `ctx.session.send_resource_list_changed()` +- Contextual logging within tool execution +- Client communication patterns + +## Image handling + +Working with images in MCP servers: + +```python +--8<-- "examples/snippets/servers/images.py" +``` + +This shows: + +- Using FastMCP's `Image` class for automatic image handling +- PIL integration for image processing +- Returning images from tools +- Image format conversion and optimization + +## Completion support + +Providing argument completion for enhanced user experience: + +```python +--8<-- "examples/snippets/servers/completion.py" +``` + +This advanced pattern demonstrates: + +- Dynamic completion based on partial input +- Context-aware suggestions (repository suggestions based on owner) +- Resource template parameter completion +- Prompt argument completion + +These advanced patterns enable rich, interactive server implementations that go beyond simple request-response workflows. \ No newline at end of file diff --git a/docs/examples-server-prompts.md b/docs/examples-server-prompts.md new file mode 100644 index 000000000..beb72ae32 --- /dev/null +++ b/docs/examples-server-prompts.md @@ -0,0 +1,42 @@ +# Server prompts examples + +Prompts are reusable templates that help structure LLM interactions. They provide a way to define consistent interaction patterns that users can invoke. + +## Basic prompts + +Simple prompt templates for common scenarios: + +```python +--8<-- "examples/snippets/servers/basic_prompt.py" +``` + +This example demonstrates: + +- Simple string prompts (`review_code`) +- Multi-message prompt conversations (`debug_error`) +- Using different message types (User and Assistant messages) +- Prompt titles for better user experience + +## Simple prompt server + +A complete server focused on prompt management: + +```python +--8<-- "examples/servers/simple-prompt/mcp_simple_prompt/server.py" +``` + +This low-level server example shows: + +- Prompt listing and retrieval +- Argument handling and validation +- Dynamic prompt generation based on parameters +- Production-ready prompt patterns using the low-level API + +Prompts are user-controlled primitives that help create consistent, reusable interaction patterns. They're particularly useful for: + +- Code review templates +- Debugging assistance workflows +- Content generation patterns +- Structured analysis requests + +Unlike tools (which are model-controlled) and resources (which are application-controlled), prompts are invoked directly by users to initiate specific types of interactions with the LLM. \ No newline at end of file diff --git a/docs/examples-server-resources.md b/docs/examples-server-resources.md new file mode 100644 index 000000000..8a736f67b --- /dev/null +++ b/docs/examples-server-resources.md @@ -0,0 +1,50 @@ +# Server resources examples + +Resources provide data to LLMs without side effects. They're similar to GET endpoints in REST APIs and should be used for exposing information rather than performing actions. + +## Basic resources + +Simple resource patterns for exposing data: + +```python +--8<-- "examples/snippets/servers/basic_resource.py" +``` + +This example demonstrates: + +- Static resources with fixed URIs (`config://settings`) +- Dynamic resources with URI templates (`file://documents/{name}`) +- Simple string data return +- JSON configuration data + +## Memory and state management + +Resources that manage server memory and state: + +```python +--8<-- "examples/fastmcp/memory.py" +``` + +This example shows how to: + +- Implement persistent memory across requests +- Store and retrieve conversational context +- Handle memory cleanup and management +- Provide memory resources to LLMs + +## Simple resource server + +A complete server focused on resource management: + +```python +--8<-- "examples/servers/simple-resource/mcp_simple_resource/server.py" +``` + +This is a full example of a low-level server that: + +- Uses the low-level server API for maximum control +- Implements resource listing and reading +- Handles URI templates and parameter extraction +- Demonstrates production-ready resource patterns + +Resources are essential for providing contextual information to LLMs, whether it's configuration data, file contents, or dynamic information that changes over time. \ No newline at end of file diff --git a/docs/examples-server-tools.md b/docs/examples-server-tools.md new file mode 100644 index 000000000..83a34e23f --- /dev/null +++ b/docs/examples-server-tools.md @@ -0,0 +1,76 @@ +# Server tools examples + +Tools are functions that LLMs can call to perform actions or computations. This section demonstrates various tool patterns and capabilities. + +## Basic tools + +Simple tools that perform computations and return results: + +```python +--8<-- "examples/snippets/servers/basic_tool.py" +``` + +## Tools with context and progress reporting + +Tools can access MCP context for logging, progress reporting, and other capabilities: + +```python +--8<-- "examples/snippets/servers/tool_progress.py" +``` + +This example shows: + +- Using the `Context` parameter for MCP capabilities +- Progress reporting during long-running operations +- Structured logging at different levels +- Async tool functions + +## Complex input handling + +Tools can handle complex data structures and validation: + +```python +--8<-- "examples/fastmcp/complex_inputs.py" +``` + +## Parameter descriptions + +Tools with detailed parameter descriptions and validation: + +```python +--8<-- "examples/fastmcp/parameter_descriptions.py" +``` + +## Unicode and internationalization + +Handling Unicode and international text in tools: + +```python +--8<-- "examples/fastmcp/unicode_example.py" +``` + +## Desktop integration + +Tools that interact with the desktop environment: + +```python +--8<-- "examples/fastmcp/desktop.py" +``` + +## Screenshot tools + +Tools for taking and processing screenshots: + +```python +--8<-- "examples/fastmcp/screenshot.py" +``` + +## Text processing tools + +Tools for text manipulation and processing: + +```python +--8<-- "examples/fastmcp/text_me.py" +``` + +All tool examples demonstrate different aspects of MCP tool development, from basic computation to complex system interactions. \ No newline at end of file diff --git a/docs/examples-structured-output.md b/docs/examples-structured-output.md new file mode 100644 index 000000000..04c2b0326 --- /dev/null +++ b/docs/examples-structured-output.md @@ -0,0 +1,69 @@ +# Structured output examples + +Structured output allows tools to return well-typed, validated data that clients can easily process. This section covers various approaches to structured data. + +## FastMCP structured output + +Using FastMCP's automatic structured output capabilities: + +```python +--8<-- "examples/snippets/servers/structured_output.py" +``` + +This comprehensive example demonstrates: + +- **Pydantic models**: Rich validation and documentation (`WeatherData`) +- **TypedDict**: Simpler structures (`LocationInfo`) +- **Dictionary types**: Flexible schemas (`dict[str, float]`) +- **Regular classes**: With type hints for structured output (`UserProfile`) +- **Untyped classes**: Fall back to unstructured output (`UntypedConfig`) +- **Primitive wrapping**: Simple types wrapped in `{"result": value}` + +## Weather service with structured output + +A complete weather service demonstrating real-world structured output patterns: + +```python +--8<-- "examples/fastmcp/weather_structured.py" +``` + +This extensive example shows: + +- **Nested Pydantic models**: Complex data structures with validation +- **Multiple output formats**: Different approaches for different use cases +- **Dataclass support**: Using dataclasses for structured output +- **Production patterns**: Realistic data structures for weather APIs +- **Testing integration**: Built-in testing via MCP protocol + +## Low-level structured output + +Using the low-level server API for maximum control: + +```python +--8<-- "examples/snippets/servers/lowlevel/structured_output.py" +``` + +And a standalone low-level example: + +```python +--8<-- "examples/servers/structured_output_lowlevel.py" +``` + +These examples demonstrate: + +- Manual schema definition with `outputSchema` +- Validation against defined schemas +- Returning structured data directly from tools +- Backward compatibility with unstructured content + +## Benefits of structured output + +Structured output provides several advantages: + +1. **Type Safety**: Automatic validation ensures data integrity +2. **Documentation**: Schemas serve as API documentation +3. **Client Integration**: Easier processing by client applications +4. **Backward Compatibility**: Still provides unstructured text content +5. **IDE Support**: Better development experience with type hints + +Choose structured output when you need reliable, processable data from your tools. \ No newline at end of file diff --git a/docs/examples-transport-http.md b/docs/examples-transport-http.md new file mode 100644 index 000000000..06e751e62 --- /dev/null +++ b/docs/examples-transport-http.md @@ -0,0 +1,91 @@ +# HTTP transport examples + +HTTP transports enable web-based MCP server deployment with support for multiple clients and scalable architectures. + +## Streamable HTTP configuration + +Basic streamable HTTP server setup with different configurations: + +```python +--8<-- "examples/snippets/servers/streamable_config.py" +``` + +This example demonstrates: + +- **Stateful servers**: Maintain session state (default) +- **Stateless servers**: No session persistence (`stateless_http=True`) +- **JSON responses**: Disable SSE streaming (`json_response=True`) +- Transport selection at runtime + +## Mounting multiple servers + +Deploying multiple MCP servers in a single Starlette application: + +```python +--8<-- "examples/snippets/servers/streamable_starlette_mount.py" +``` + +This pattern shows: + +- Creating multiple FastMCP server instances +- Mounting servers at different paths (`/echo`, `/math`) +- Shared lifespan management across servers +- Combined session manager lifecycle + +## Stateful HTTP server + +Full low-level implementation of a stateful HTTP server: + +```python +--8<-- "examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py" +``` + +This comprehensive example includes: + +- Event store for resumability (reconnection support) +- Progress notifications and logging +- Resource change notifications +- Streaming tool execution with progress updates +- Production-ready ASGI integration + +## Stateless HTTP server + +Low-level stateless server for high-scale deployments: + +```python +--8<-- "examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py" +``` + +Features of stateless design: + +- No session state persistence +- Simplified architecture for load balancing +- Better horizontal scaling capabilities +- Each request is independent + +## Event store implementation + +Supporting resumable connections with event storage: + +```python +--8<-- "examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py" +``` + +This component enables: + +- Client reconnection with `Last-Event-ID` headers +- Event replay for missed messages +- Persistent streaming across connection interruptions +- Production-ready resumability patterns + +## Transport comparison + +| Feature | Streamable HTTP | SSE | stdio | +|---------|----------------|-----|-------| +| **Resumability** | ✅ With event store | ❌ | ❌ | +| **Scalability** | ✅ Multi-client | ✅ Multi-client | ❌ Single process | +| **State** | Configurable | Session-based | Process-based | +| **Deployment** | Web servers | Web servers | Local execution | +| **Best for** | Production APIs | Real-time updates | Development/CLI | + +Choose HTTP transports for production deployments that need to serve multiple clients or integrate with web infrastructure. \ No newline at end of file diff --git a/docs/images.md b/docs/images.md deleted file mode 100644 index 5129f96a4..000000000 --- a/docs/images.md +++ /dev/null @@ -1,780 +0,0 @@ -# Images - -The MCP Python SDK provides comprehensive support for working with image data in tools and resources. The `Image` class handles image processing, validation, and format conversion automatically. - -## Image basics - -### The Image class - -The `Image` class automatically handles image data and provides convenient methods for common operations: - -```python -from mcp.server.fastmcp import FastMCP, Image - -mcp = FastMCP("Image Processing Server") - -@mcp.tool() -def create_simple_image() -> Image: - """Create a simple colored image.""" - from PIL import Image as PILImage - import io - - # Create a simple red square - img = PILImage.new('RGB', (100, 100), color='red') - - # Convert to bytes - img_bytes = io.BytesIO() - img.save(img_bytes, format='PNG') - img_bytes.seek(0) - - return Image(data=img_bytes.getvalue(), format="png") -``` - -### Working with PIL (Pillow) - -The most common pattern is using PIL/Pillow for image operations: - -```python -from PIL import Image as PILImage, ImageDraw, ImageFont -import io - -@mcp.tool() -def create_text_image(text: str, width: int = 400, height: int = 200) -> Image: - """Create an image with text.""" - # Create a white background - img = PILImage.new('RGB', (width, height), color='white') - draw = ImageDraw.Draw(img) - - # Try to use a default font, fall back to PIL default - try: - font = ImageFont.truetype("arial.ttf", 24) - except OSError: - font = ImageFont.load_default() - - # Calculate text position (centered) - bbox = draw.textbbox((0, 0), text, font=font) - text_width = bbox[2] - bbox[0] - text_height = bbox[3] - bbox[1] - x = (width - text_width) // 2 - y = (height - text_height) // 2 - - # Draw text - draw.text((x, y), text, fill='black', font=font) - - # Convert to bytes - img_buffer = io.BytesIO() - img.save(img_buffer, format='PNG') - img_buffer.seek(0) - - return Image(data=img_buffer.getvalue(), format="png") - -@mcp.tool() -def create_thumbnail(image_data: bytes, size: tuple[int, int] = (128, 128)) -> Image: - """Create a thumbnail from image data.""" - # Load image from bytes - img_buffer = io.BytesIO(image_data) - img = PILImage.open(img_buffer) - - # Create thumbnail (maintains aspect ratio) - img.thumbnail(size, PILImage.Resampling.LANCZOS) - - # Convert back to bytes - output_buffer = io.BytesIO() - img.save(output_buffer, format='PNG') - output_buffer.seek(0) - - return Image(data=output_buffer.getvalue(), format="png") -``` - -## Image processing tools - -### Basic image operations - -```python -from PIL import Image as PILImage, ImageFilter, ImageEnhance -import io - -@mcp.tool() -def apply_blur(image_data: bytes, radius: float = 2.0) -> Image: - """Apply Gaussian blur to an image.""" - # Load image - img = PILImage.open(io.BytesIO(image_data)) - - # Apply blur filter - blurred = img.filter(ImageFilter.GaussianBlur(radius=radius)) - - # Convert to bytes - output = io.BytesIO() - blurred.save(output, format='PNG') - output.seek(0) - - return Image(data=output.getvalue(), format="png") - -@mcp.tool() -def adjust_brightness(image_data: bytes, factor: float = 1.5) -> Image: - """Adjust image brightness.""" - if not 0.1 <= factor <= 3.0: - raise ValueError("Brightness factor must be between 0.1 and 3.0") - - img = PILImage.open(io.BytesIO(image_data)) - - # Adjust brightness - enhancer = ImageEnhance.Brightness(img) - brightened = enhancer.enhance(factor) - - output = io.BytesIO() - brightened.save(output, format='PNG') - output.seek(0) - - return Image(data=output.getvalue(), format="png") - -@mcp.tool() -def resize_image( - image_data: bytes, - width: int, - height: int, - maintain_aspect: bool = True -) -> Image: - """Resize an image to specified dimensions.""" - img = PILImage.open(io.BytesIO(image_data)) - - if maintain_aspect: - # Calculate size maintaining aspect ratio - img.thumbnail((width, height), PILImage.Resampling.LANCZOS) - resized = img - else: - # Force exact dimensions - resized = img.resize((width, height), PILImage.Resampling.LANCZOS) - - output = io.BytesIO() - resized.save(output, format='PNG') - output.seek(0) - - return Image(data=output.getvalue(), format="png") - -@mcp.tool() -def convert_format(image_data: bytes, target_format: str) -> Image: - """Convert image to different format.""" - supported_formats = ['PNG', 'JPEG', 'WEBP', 'GIF', 'BMP'] - target_format = target_format.upper() - - if target_format not in supported_formats: - raise ValueError(f"Unsupported format. Use one of: {supported_formats}") - - img = PILImage.open(io.BytesIO(image_data)) - - # Handle JPEG (no alpha channel) - if target_format == 'JPEG' and img.mode in ('RGBA', 'LA', 'P'): - # Convert to RGB (white background) - background = PILImage.new('RGB', img.size, 'white') - if img.mode == 'P': - img = img.convert('RGBA') - background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) - img = background - - output = io.BytesIO() - img.save(output, format=target_format) - output.seek(0) - - return Image(data=output.getvalue(), format=target_format.lower()) -``` - -### Advanced image operations - -```python -from PIL import Image as PILImage, ImageOps -import io - -@mcp.tool() -def create_collage(images: list[bytes], grid_size: tuple[int, int] = (2, 2)) -> Image: - """Create a collage from multiple images.""" - if len(images) > grid_size[0] * grid_size[1]: - raise ValueError(f"Too many images for {grid_size[0]}x{grid_size[1]} grid") - - if not images: - raise ValueError("At least one image is required") - - # Load all images - pil_images = [PILImage.open(io.BytesIO(img_data)) for img_data in images] - - # Calculate cell size (use first image as reference) - cell_width = pil_images[0].width - cell_height = pil_images[0].height - - # Resize all images to match the first one - resized_images = [] - for img in pil_images: - resized = img.resize((cell_width, cell_height), PILImage.Resampling.LANCZOS) - resized_images.append(resized) - - # Create collage canvas - canvas_width = cell_width * grid_size[0] - canvas_height = cell_height * grid_size[1] - collage = PILImage.new('RGB', (canvas_width, canvas_height), 'white') - - # Paste images into grid - for idx, img in enumerate(resized_images): - row = idx // grid_size[0] - col = idx % grid_size[0] - x = col * cell_width - y = row * cell_height - collage.paste(img, (x, y)) - - # Convert to bytes - output = io.BytesIO() - collage.save(output, format='PNG') - output.seek(0) - - return Image(data=output.getvalue(), format="png") - -@mcp.tool() -def add_border( - image_data: bytes, - border_width: int = 10, - border_color: str = "black" -) -> Image: - """Add a border around an image.""" - img = PILImage.open(io.BytesIO(image_data)) - - # Add border - bordered = ImageOps.expand(img, border=border_width, fill=border_color) - - output = io.BytesIO() - bordered.save(output, format='PNG') - output.seek(0) - - return Image(data=output.getvalue(), format="png") - -@mcp.tool() -def apply_filters(image_data: bytes, filter_name: str) -> Image: - """Apply various filters to an image.""" - img = PILImage.open(io.BytesIO(image_data)) - - filters = { - "blur": ImageFilter.BLUR, - "contour": ImageFilter.CONTOUR, - "detail": ImageFilter.DETAIL, - "edge_enhance": ImageFilter.EDGE_ENHANCE, - "emboss": ImageFilter.EMBOSS, - "find_edges": ImageFilter.FIND_EDGES, - "sharpen": ImageFilter.SHARPEN, - "smooth": ImageFilter.SMOOTH - } - - if filter_name not in filters: - raise ValueError(f"Unknown filter. Available: {list(filters.keys())}") - - filtered = img.filter(filters[filter_name]) - - output = io.BytesIO() - filtered.save(output, format='PNG') - output.seek(0) - - return Image(data=output.getvalue(), format="png") -``` - -## Chart and visualization generation - -### Creating charts with matplotlib - -```python -import matplotlib.pyplot as plt -import matplotlib -import io -import numpy as np - -# Use non-interactive backend -matplotlib.use('Agg') - -@mcp.tool() -def create_line_chart( - data: list[float], - labels: list[str] | None = None, - title: str = "Line Chart" -) -> Image: - """Create a line chart from data.""" - plt.figure(figsize=(10, 6)) - - x_values = labels if labels else list(range(len(data))) - plt.plot(x_values, data, marker='o', linewidth=2, markersize=6) - - plt.title(title, fontsize=16) - plt.xlabel("X Axis") - plt.ylabel("Y Axis") - plt.grid(True, alpha=0.3) - - # Rotate x-axis labels if they're strings - if labels and isinstance(labels[0], str): - plt.xticks(rotation=45) - - plt.tight_layout() - - # Save to bytes - img_buffer = io.BytesIO() - plt.savefig(img_buffer, format='PNG', dpi=150, bbox_inches='tight') - img_buffer.seek(0) - plt.close() - - return Image(data=img_buffer.getvalue(), format="png") - -@mcp.tool() -def create_bar_chart( - values: list[float], - categories: list[str], - title: str = "Bar Chart", - color: str = "steelblue" -) -> Image: - """Create a bar chart.""" - if len(values) != len(categories): - raise ValueError("Values and categories must have the same length") - - plt.figure(figsize=(10, 6)) - bars = plt.bar(categories, values, color=color, alpha=0.8) - - plt.title(title, fontsize=16) - plt.xlabel("Categories") - plt.ylabel("Values") - - # Add value labels on bars - for bar, value in zip(bars, values): - plt.text(bar.get_x() + bar.get_width()/2, bar.get_height() + max(values)*0.01, - f'{value:.1f}', ha='center', va='bottom') - - plt.xticks(rotation=45) - plt.grid(axis='y', alpha=0.3) - plt.tight_layout() - - img_buffer = io.BytesIO() - plt.savefig(img_buffer, format='PNG', dpi=150, bbox_inches='tight') - img_buffer.seek(0) - plt.close() - - return Image(data=img_buffer.getvalue(), format="png") - -@mcp.tool() -def create_pie_chart( - values: list[float], - labels: list[str], - title: str = "Pie Chart" -) -> Image: - """Create a pie chart.""" - if len(values) != len(labels): - raise ValueError("Values and labels must have the same length") - - plt.figure(figsize=(8, 8)) - - # Create pie chart with percentages - wedges, texts, autotexts = plt.pie( - values, - labels=labels, - autopct='%1.1f%%', - startangle=90, - colors=plt.cm.Set3.colors - ) - - plt.title(title, fontsize=16) - - # Equal aspect ratio ensures pie is drawn as circle - plt.axis('equal') - - img_buffer = io.BytesIO() - plt.savefig(img_buffer, format='PNG', dpi=150, bbox_inches='tight') - img_buffer.seek(0) - plt.close() - - return Image(data=img_buffer.getvalue(), format="png") - -@mcp.tool() -def create_scatter_plot( - x_data: list[float], - y_data: list[float], - title: str = "Scatter Plot", - x_label: str = "X Axis", - y_label: str = "Y Axis" -) -> Image: - """Create a scatter plot.""" - if len(x_data) != len(y_data): - raise ValueError("X and Y data must have the same length") - - plt.figure(figsize=(10, 6)) - plt.scatter(x_data, y_data, alpha=0.6, s=50, color='steelblue') - - plt.title(title, fontsize=16) - plt.xlabel(x_label) - plt.ylabel(y_label) - plt.grid(True, alpha=0.3) - - plt.tight_layout() - - img_buffer = io.BytesIO() - plt.savefig(img_buffer, format='PNG', dpi=150, bbox_inches='tight') - img_buffer.seek(0) - plt.close() - - return Image(data=img_buffer.getvalue(), format="png") -``` - -## Image analysis tools - -### Image information extraction - -```python -from PIL import Image as PILImage, ExifTags -from PIL.ExifTags import TAGS -import io - -@mcp.tool() -def analyze_image(image_data: bytes) -> dict: - """Analyze an image and extract information.""" - img = PILImage.open(io.BytesIO(image_data)) - - analysis = { - "format": img.format, - "mode": img.mode, - "size": { - "width": img.width, - "height": img.height - }, - "aspect_ratio": round(img.width / img.height, 2), - "has_transparency": img.mode in ('RGBA', 'LA') or 'transparency' in img.info - } - - # Calculate file size - analysis["file_size_bytes"] = len(image_data) - analysis["file_size_kb"] = round(len(image_data) / 1024, 2) - - # Extract color information - if img.mode == 'RGB': - # Sample dominant colors (simplified) - colors = img.getcolors(maxcolors=256*256*256) - if colors: - # Get most common color - most_common = max(colors, key=lambda x: x[0]) - analysis["dominant_color"] = { - "rgb": most_common[1], - "pixel_count": most_common[0] - } - - # Try to extract EXIF data - try: - exifdata = img.getexif() - if exifdata: - exif_info = {} - for tag_id in exifdata: - tag = TAGS.get(tag_id, tag_id) - data = exifdata.get(tag_id) - # Only include readable string/numeric data - if isinstance(data, (str, int, float)): - exif_info[tag] = data - analysis["exif"] = exif_info - except: - analysis["exif"] = None - - return analysis - -@mcp.tool() -def get_color_palette(image_data: bytes, num_colors: int = 5) -> dict: - """Extract a color palette from an image.""" - img = PILImage.open(io.BytesIO(image_data)) - - # Convert to RGB if necessary - if img.mode != 'RGB': - img = img.convert('RGB') - - # Resize image for faster processing - img = img.resize((150, 150), PILImage.Resampling.LANCZOS) - - # Get colors using PIL's quantize - quantized = img.quantize(colors=num_colors) - palette_colors = quantized.getpalette() - - # Extract RGB tuples - colors = [] - for i in range(num_colors): - r = palette_colors[i * 3] - g = palette_colors[i * 3 + 1] - b = palette_colors[i * 3 + 2] - - # Convert to hex - hex_color = f"#{r:02x}{g:02x}{b:02x}" - - colors.append({ - "rgb": [r, g, b], - "hex": hex_color - }) - - return { - "palette": colors, - "num_colors": len(colors) - } -``` - -## Resource-based image serving - -### Image resources - -```python -import os -from pathlib import Path - -# Define allowed image directory -IMAGE_DIR = Path("/safe/images") - -@mcp.resource("image://{filename}") -def get_image(filename: str) -> str: - """Get image data as base64 encoded string.""" - import base64 - - # Security: validate filename - if ".." in filename or "/" in filename: - raise ValueError("Invalid filename") - - image_path = IMAGE_DIR / filename - - if not image_path.exists(): - raise ValueError(f"Image {filename} not found") - - # Read image file - try: - image_data = image_path.read_bytes() - - # Determine MIME type based on extension - ext = image_path.suffix.lower() - mime_types = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp' - } - mime_type = mime_types.get(ext, 'application/octet-stream') - - # Encode as base64 - encoded_data = base64.b64encode(image_data).decode('utf-8') - - return f"data:{mime_type};base64,{encoded_data}" - - except Exception as e: - raise ValueError(f"Cannot read image {filename}: {e}") - -@mcp.resource("images://list") -def list_images() -> str: - """List all available images.""" - try: - image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'} - images = [ - f.name for f in IMAGE_DIR.iterdir() - if f.is_file() and f.suffix.lower() in image_extensions - ] - - if not images: - return "No images available" - - result = "Available images:\\n" - for img in sorted(images): - img_path = IMAGE_DIR / img - size = img_path.stat().st_size - result += f"- {img} ({size} bytes)\\n" - - return result - - except Exception as e: - return f"Cannot list images: {e}" -``` - -## Error handling and validation - -### Image validation - -```python -def validate_image_data(image_data: bytes) -> bool: - """Validate that data is a valid image.""" - try: - img = PILImage.open(io.BytesIO(image_data)) - img.verify() # Check if image is corrupted - return True - except Exception: - return False - -@mcp.tool() -def safe_image_operation(image_data: bytes, operation: str) -> Image: - """Perform image operations with validation.""" - # Validate input - if not image_data: - raise ValueError("No image data provided") - - if not validate_image_data(image_data): - raise ValueError("Invalid or corrupted image data") - - # Check file size (limit to 10MB) - max_size = 10 * 1024 * 1024 # 10MB - if len(image_data) > max_size: - raise ValueError(f"Image too large: {len(image_data)} bytes (max: {max_size})") - - img = PILImage.open(io.BytesIO(image_data)) - - # Check image dimensions - max_dimension = 4000 - if img.width > max_dimension or img.height > max_dimension: - raise ValueError(f"Image dimensions too large: {img.width}x{img.height} (max: {max_dimension})") - - # Perform operation - if operation == "normalize": - # Convert to standard RGB format - if img.mode != 'RGB': - img = img.convert('RGB') - elif operation == "thumbnail": - img.thumbnail((256, 256), PILImage.Resampling.LANCZOS) - else: - raise ValueError(f"Unknown operation: {operation}") - - # Convert back to bytes - output = io.BytesIO() - img.save(output, format='PNG') - output.seek(0) - - return Image(data=output.getvalue(), format="png") -``` - -## Testing image tools - -### Unit testing with mock images - -```python -import pytest -from PIL import Image as PILImage -import io - -def create_test_image(width: int = 100, height: int = 100, color: str = 'red') -> bytes: - """Create a test image for unit testing.""" - img = PILImage.new('RGB', (width, height), color=color) - buffer = io.BytesIO() - img.save(buffer, format='PNG') - buffer.seek(0) - return buffer.getvalue() - -def test_image_resize(): - """Test image resizing functionality.""" - # Create test image - test_data = create_test_image(200, 200, 'blue') - - # Test resize function - mcp = FastMCP("Test") - - @mcp.tool() - def resize_test(image_data: bytes, width: int, height: int) -> Image: - img = PILImage.open(io.BytesIO(image_data)) - resized = img.resize((width, height), PILImage.Resampling.LANCZOS) - output = io.BytesIO() - resized.save(output, format='PNG') - output.seek(0) - return Image(data=output.getvalue(), format="png") - - result = resize_test(test_data, 50, 50) - - # Verify result - assert isinstance(result, Image) - assert result.format == "png" - - # Verify dimensions - result_img = PILImage.open(io.BytesIO(result.data)) - assert result_img.size == (50, 50) - -def test_image_analysis(): - """Test image analysis functionality.""" - test_data = create_test_image(300, 200, 'green') - - analysis = analyze_image(test_data) - - assert analysis["size"]["width"] == 300 - assert analysis["size"]["height"] == 200 - assert analysis["format"] == "PNG" - assert analysis["aspect_ratio"] == 1.5 -``` - -## Performance optimization - -### Image processing optimization - -```python -from concurrent.futures import ThreadPoolExecutor -import asyncio - -@mcp.tool() -async def batch_process_images( - images: list[bytes], - operation: str, - ctx: Context -) -> list[Image]: - """Process multiple images efficiently.""" - await ctx.info(f"Processing {len(images)} images with operation: {operation}") - - def process_single_image(img_data: bytes) -> Image: - """Process a single image (runs in thread pool).""" - img = PILImage.open(io.BytesIO(img_data)) - - if operation == "thumbnail": - img.thumbnail((128, 128), PILImage.Resampling.LANCZOS) - elif operation == "grayscale": - img = img.convert('L') - - output = io.BytesIO() - img.save(output, format='PNG') - output.seek(0) - - return Image(data=output.getvalue(), format="png") - - # Process images in parallel using thread pool - loop = asyncio.get_event_loop() - with ThreadPoolExecutor(max_workers=4) as executor: - tasks = [ - loop.run_in_executor(executor, process_single_image, img_data) - for img_data in images - ] - - results = [] - for i, task in enumerate(asyncio.as_completed(tasks)): - result = await task - results.append(result) - - # Report progress - progress = (i + 1) / len(images) - await ctx.report_progress( - progress=progress, - message=f"Processed {i + 1}/{len(images)} images" - ) - - await ctx.info("Batch processing completed") - return results -``` - -## Best practices - -### Image handling guidelines - -- **Validate inputs** - Always verify image data before processing -- **Limit sizes** - Set reasonable limits on image dimensions and file sizes -- **Use appropriate formats** - Choose the right format for the use case -- **Handle errors gracefully** - Provide clear error messages for invalid images -- **Optimize performance** - Use threading for batch operations - -### Memory management - -- **Process in batches** - Don't load too many large images at once -- **Close PIL images** - Let PIL handle garbage collection -- **Use BytesIO efficiently** - Reuse buffers when possible -- **Monitor memory usage** - Be aware of memory consumption for large images - -### Security considerations - -- **Validate image formats** - Only allow expected image types -- **Limit processing time** - Set timeouts for complex operations -- **Sanitize filenames** - Prevent path traversal attacks -- **Check file sizes** - Prevent denial of service through large uploads - -## Next steps - -- **[Advanced tools](tools.md)** - Building complex image processing workflows -- **[Context usage](context.md)** - Progress reporting for long image operations -- **[Resource patterns](resources.md)** - Serving images through resources -- **[Authentication](authentication.md)** - Securing image processing endpoints \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 4b5697998..4262bbe96 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,81 +2,85 @@ A Python implementation of the Model Context Protocol (MCP) that enables applications to provide context for LLMs in a standardized way. -## Overview - -The Model Context Protocol allows you to build servers that expose data and functionality to LLM applications securely. This Python SDK implements the full MCP specification with both high-level FastMCP and low-level server implementations. - -### Key features - -- **FastMCP server framework** - High-level, decorator-based server creation -- **Multiple transports** - stdio, SSE, and Streamable HTTP support -- **Type-safe development** - Full type hints and Pydantic integration -- **Authentication support** - OAuth 2.1 resource server capabilities -- **Rich tooling** - Built-in development and deployment utilities - -## Quick start - -Create a simple MCP server in minutes: - -```python -from mcp.server.fastmcp import FastMCP - -# Create server -mcp = FastMCP("Demo") - -@mcp.tool() -def add(a: int, b: int) -> int: - """Add two numbers""" - return a + b - -@mcp.resource("greeting://{name}") -def get_greeting(name: str) -> str: - """Get a personalized greeting""" - return f"Hello, {name}!" -``` - -Install in Claude Desktop: - -```bash -uv run mcp install server.py -``` - -## Documentation sections - -### Getting started -- **[Quickstart](quickstart.md)** - Build your first MCP server -- **[Installation](installation.md)** - Setup and dependencies - -### Core concepts -- **[Servers](servers.md)** - Server creation and lifecycle management -- **[Resources](resources.md)** - Exposing data to LLMs -- **[Tools](tools.md)** - Creating LLM-callable functions -- **[Prompts](prompts.md)** - Reusable interaction templates -- **[Context](context.md)** - Request context and capabilities - -### Advanced features -- **[Images](images.md)** - Working with image data -- **[Authentication](authentication.md)** - OAuth 2.1 implementation -- **[Sampling](sampling.md)** - LLM text generation -- **[Elicitation](elicitation.md)** - User input collection -- **[Progress & logging](progress-logging.md)** - Status updates and notifications - -### Transport & deployment -- **[Running servers](running-servers.md)** - Development and production deployment -- **[Streamable HTTP](streamable-http.md)** - Modern HTTP transport -- **[ASGI integration](asgi-integration.md)** - Mounting to existing web servers - -### Client development -- **[Writing clients](writing-clients.md)** - MCP client implementation -- **[OAuth for clients](oauth-clients.md)** - Client-side authentication -- **[Display utilities](display-utilities.md)** - UI helper functions -- **[Parsing results](parsing-results.md)** - Handling tool responses - -### Advanced usage -- **[Low-level server](low-level-server.md)** - Direct protocol implementation -- **[Structured output](structured-output.md)** - Advanced type patterns -- **[Completions](completions.md)** - Argument completion system - -## API reference - -Complete API documentation is auto-generated from the source code and available in the [API Reference](reference/) section. +## Examples + +The [Examples](examples-quickstart.md) section provides working code examples covering many aspects of MCP development. Each code example in these documents corresponds to an example .py file in the examples/ directory in the repository. + +- [Getting started](examples-quickstart.md): Quick introduction to FastMCP and basic server patterns +- [Server development](examples-server-tools.md): Tools, resources, prompts, and structured output examples +- [Transport protocols](examples-transport-http.md): HTTP and streamable transport implementations +- [Low-level servers](examples-lowlevel-servers.md): Advanced patterns using the low-level server API +- [Authentication](examples-authentication.md): OAuth 2.1 server and client implementations +- [Client development](examples-clients.md): Complete client examples with various connection types + +## API Reference + +Complete API documentation is auto-generated from the source code and available in the [API Reference](reference/mcp/index.md) section. + +## Code example index + +### Servers + +| File | Transport | Resources | Prompts | Tools | Completions | Sampling | Elicitation | Progress | Logging | Authentication | Configuration | +|---|---|---|---|---|---|---|---|---|---|---|---| +| [Complex input handling](examples-server-tools.md#complex-input-handling) | stdio | — | — | ✅ | — | — | — | — | — | — | — | +| [Desktop integration](examples-server-tools.md#desktop-integration) | stdio | ✅ | — | ✅ | — | — | — | — | — | — | — | +| [Enhanced echo server](examples-echo-servers.md#enhanced-echo-server) | stdio | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | +| [Memory and state management](examples-server-resources.md#memory-and-state-management) | stdio | — | — | ✅ | — | — | — | — | — | — | ✅ | +| [Parameter descriptions](examples-server-tools.md#parameter-descriptions) | stdio | — | — | ✅ | — | — | — | — | — | — | — | +| [Basic readme example](examples-quickstart.md#basic-readme-example) | stdio | ✅ | — | ✅ | — | — | — | — | — | — | — | +| [Screenshot tools](examples-server-tools.md#screenshot-tools) | stdio | — | — | ✅ | — | — | — | — | — | — | — | +| [Simple echo server](examples-echo-servers.md#simple-echo-server) | stdio | — | — | ✅ | — | — | — | — | — | — | — | +| [Text processing tools](examples-server-tools.md#text-processing-tools) | stdio | — | — | ✅ | — | — | — | — | — | — | ✅ | +| [Unicode and internationalization](examples-server-tools.md#unicode-and-internationalization) | stdio | — | — | ✅ | — | — | — | — | — | — | — | +| [Weather service with structured output](examples-structured-output.md#weather-service-with-structured-output) | stdio | — | — | ✅ | — | — | — | — | — | — | — | +| [Complete authentication server](examples-authentication.md#complete-authentication-server) | stdio | — | — | — | — | — | — | — | — | ✅ | — | +| [Legacy Authorization Server](examples-authentication.md#legacy-authorization-server) | streamable-http | — | — | ✅ | — | — | — | — | — | ✅ | ✅ | +| [Resource server with introspection](examples-authentication.md#resource-server-with-introspection) | streamable-http | — | — | ✅ | — | — | — | — | — | ✅ | ✅ | +| [Simple prompt server](examples-server-prompts.md#simple-prompt-server) | stdio | — | ✅ | — | — | — | — | — | — | — | — | +| [Simple resource server](examples-server-resources.md#simple-resource-server) | stdio | ✅ | — | — | — | — | — | — | — | — | — | +| [Stateless HTTP server](examples-transport-http.md#stateless-http-server) | streamable-http | — | — | ✅ | — | — | — | — | ✅ | — | ✅ | +| [Stateful HTTP server](examples-transport-http.md#stateful-http-server) | streamable-http | — | — | ✅ | — | — | — | — | ✅ | — | ✅ | +| [Simple tool server](examples-lowlevel-servers.md#simple-tool-server) | stdio | — | — | ✅ | — | — | — | — | — | — | — | +| [Low-level structured output](examples-structured-output.md#low-level-structured-output) | stdio | — | — | ✅ | — | — | — | — | — | — | — | +| [Basic prompts](examples-server-prompts.md#basic-prompts) | stdio | — | ✅ | — | — | — | — | — | — | — | — | +| [Basic resources](examples-server-resources.md#basic-resources) | stdio | ✅ | — | — | — | — | — | — | — | — | — | +| [Basic tools](examples-server-tools.md#basic-tools) | stdio | — | — | ✅ | — | — | — | — | — | — | — | +| [Completion support](examples-server-advanced.md#completion-support) | stdio | ✅ | ✅ | — | ✅ | — | — | — | — | — | — | +| [Direct execution](examples-quickstart.md#direct-execution) | stdio | — | — | ✅ | — | — | — | — | — | — | — | +| [User interaction and elicitation](examples-server-advanced.md#user-interaction-and-elicitation) | stdio | — | — | ✅ | — | — | ✅ | — | — | — | — | +| [FastMCP quickstart](examples-quickstart.md#fastmcp-quickstart) | stdio | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | +| [Image handling](examples-server-advanced.md#image-handling) | stdio | — | — | ✅ | — | — | — | — | — | — | — | +| [Lifespan management](examples-server-advanced.md#lifespan-management) | stdio | — | — | ✅ | — | — | — | — | — | — | ✅ | +| [Basic low-level server](examples-lowlevel-servers.md#basic-low-level-server) | stdio | — | ✅ | — | — | — | — | — | — | — | — | +| [Low-level server with lifespan](examples-lowlevel-servers.md#low-level-server-with-lifespan) | stdio | — | — | ✅ | — | — | — | — | — | — | ✅ | +| [Low-level structured output](examples-structured-output.md#low-level-structured-output) | stdio | — | — | ✅ | — | — | — | — | — | — | — | +| [Logging and notifications](examples-server-advanced.md#logging-and-notifications) | stdio | — | — | ✅ | — | — | — | — | ✅ | — | — | +| [OAuth server implementation](examples-authentication.md#oauth-server-implementation) | streamable-http | — | — | ✅ | — | — | — | — | — | ✅ | — | +| [LLM sampling and integration](examples-server-advanced.md#llm-sampling-and-integration) | stdio | — | — | ✅ | — | ✅ | — | — | — | — | — | +| [Streamable HTTP configuration](examples-transport-http.md#streamable-http-configuration) | streamable-http | — | — | ✅ | — | — | — | — | — | — | ✅ | +| [Mounting multiple servers](examples-transport-http.md#mounting-multiple-servers) | streamable-http | — | — | ✅ | — | — | — | — | — | — | ✅ | +| [FastMCP structured output](examples-structured-output.md#fastmcp-structured-output) | stdio | — | — | ✅ | — | — | — | — | — | — | — | +| [Tools with context and progress reporting](examples-server-tools.md#tools-with-context-and-progress-reporting) | stdio | — | — | ✅ | — | — | — | ✅ | ✅ | — | — | + + +### Clients + +| File | Transport | Resources | Prompts | Tools | Completions | Sampling | Authentication | +|---|---|---|---|---|---|---|---| +| [Authentication client](examples-clients.md#authentication-client) | streamable-http | — | — | ✅ | — | — | ✅ | +| [Complete chatbot client](examples-clients.md#complete-chatbot-client) | stdio | — | — | ✅ | — | — | — | +| [Completion client](examples-clients.md#completion-client) | stdio | ✅ | ✅ | — | ✅ | — | — | +| [Display utilities](examples-clients.md#display-utilities) | stdio | ✅ | — | ✅ | — | — | — | +| [OAuth authentication client](examples-clients.md#oauth-authentication-client) | streamable-http | ✅ | — | ✅ | — | — | ✅ | +| [Tool result parsing](examples-clients.md#tool-result-parsing) | stdio | — | — | ✅ | — | — | — | +| [Basic stdio client](examples-clients.md#basic-stdio-client) | stdio | ✅ | ✅ | ✅ | — | ✅ | — | +| [Streamable HTTP client](examples-clients.md#streamable-http-client) | streamable-http | — | — | ✅ | — | — | — | + +Notes: + +- **Resources** for clients indicates the example uses the Resources API (reading resources or listing resource templates). +- **Completions** refers to the completion/complete API for argument autocompletion. +- **Sampling** indicates the example exercises the sampling/createMessage flow (server-initiated in server examples; client-provided callback in stdio_client). +- **Authentication** indicates OAuth support is implemented in the example. +- Em dash (—) indicates **not demonstrated** in the example. diff --git a/docs/installation.md b/docs/installation.md deleted file mode 100644 index 82f261ce8..000000000 --- a/docs/installation.md +++ /dev/null @@ -1,194 +0,0 @@ -# Installation - -Learn how to install and set up the MCP Python SDK for different use cases. - -## Prerequisites - -- **Python 3.10 or later** -- **uv package manager** (recommended) or pip - -### Installing uv - -If you don't have uv installed: - -```bash -# macOS and Linux -curl -LsSf https://astral.sh/uv/install.sh | sh - -# Windows -powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" - -# Or with pip -pip install uv -``` - -## Installation methods - -### For new projects (recommended) - -Create a new uv-managed project: - -```bash -uv init my-mcp-server -cd my-mcp-server -uv add "mcp[cli]" -``` - -This creates a complete project structure with: -- `pyproject.toml` - Project configuration -- `src/` directory for your code -- Virtual environment management - -### Add to existing project - -If you have an existing project: - -```bash -uv add "mcp[cli]" -``` - -### Using pip - -For projects that use pip: - -```bash -pip install "mcp[cli]" -``` - -## Package variants - -The MCP SDK offers different installation options: - -### Core package -```bash -uv add mcp -``` - -Includes: -- Core MCP protocol implementation -- FastMCP server framework -- Client libraries -- All transport types (stdio, SSE, Streamable HTTP) - -### CLI tools -```bash -uv add "mcp[cli]" -``` - -Adds CLI utilities for: -- `mcp dev` - Development server with web inspector -- `mcp install` - Claude Desktop integration -- `mcp run` - Direct server execution - -### Rich output -```bash -uv add "mcp[rich]" -``` - -Adds enhanced terminal output with colors and formatting. - -### WebSocket support -```bash -uv add "mcp[ws]" -``` - -Adds WebSocket transport capabilities. - -### All features -```bash -uv add "mcp[cli,rich,ws]" -``` - -## Development setup - -For contributing to the MCP SDK or advanced development: - -```bash -git clone https://github.com/modelcontextprotocol/python-sdk -cd python-sdk -uv sync --group docs --group dev -``` - -This installs: -- All dependencies -- Development tools (ruff, pyright, pytest) -- Documentation tools (mkdocs, mkdocs-material) - -## Verify installation - -Test your installation: - -```bash -# Check MCP CLI is available -uv run mcp --help - -# Create and test a simple server -echo 'from mcp.server.fastmcp import FastMCP -mcp = FastMCP("Test") -@mcp.tool() -def hello() -> str: - return "Hello from MCP!" -if __name__ == "__main__": - mcp.run()' > test_server.py - -# Test the server -uv run mcp dev test_server.py -``` - -If successful, you'll see the MCP Inspector web interface open. - -## IDE integration - -### VS Code - -For the best development experience, install: - -- **Python extension** - Python language support -- **Pylance** - Advanced Python features -- **Ruff** - Code formatting and linting - -### Type checking - -The MCP SDK includes comprehensive type hints. Enable strict type checking: - -```bash -# Check types -uv run pyright - -# In VS Code, add to settings.json: -{ - "python.analysis.typeCheckingMode": "strict" -} -``` - -## Troubleshooting - -### Common issues - -**"mcp command not found"** -- Ensure uv is in your PATH -- Try `uv run mcp` instead of just `mcp` - -**Import errors** -- Verify installation: `uv run python -c "import mcp; print(mcp.__version__)"` -- Check you're in the right directory/virtual environment - -**Permission errors on Windows** -- Run terminal as administrator for global installations -- Use `--user` flag with pip if needed - -**Python version conflicts** -- Check version: `python --version` -- Use specific Python: `uv python install 3.11` then `uv python use 3.11` - -### Getting help - -- **GitHub Issues**: [Report bugs and feature requests](https://github.com/modelcontextprotocol/python-sdk/issues) -- **Discussions**: [Community support](https://github.com/modelcontextprotocol/python-sdk/discussions) -- **Documentation**: [Official MCP docs](https://modelcontextprotocol.io) - -## Next steps - -- **[Build your first server](quickstart.md)** - Follow the quickstart guide -- **[Learn core concepts](servers.md)** - Understand MCP fundamentals -- **[Explore examples](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples)** - See real-world implementations \ No newline at end of file diff --git a/docs/low-level-server.md b/docs/low-level-server.md deleted file mode 100644 index e267e7eea..000000000 --- a/docs/low-level-server.md +++ /dev/null @@ -1,1299 +0,0 @@ -# Low-level server - -Learn how to build MCP servers using the low-level protocol implementation for maximum control and customization. - -## Overview - -Low-level server development provides: - -- **Protocol control** - Direct access to MCP protocol messages -- **Custom transports** - Implement custom transport mechanisms -- **Advanced error handling** - Fine-grained error control and reporting -- **Performance optimization** - Optimize for specific use cases -- **Protocol extensions** - Add custom protocol features - -## Basic low-level server - -### Core server implementation - -```python -""" -Low-level MCP server implementation. -""" - -import asyncio -import json -import logging -from typing import Any, Dict, List, Optional, Callable, Awaitable -from dataclasses import dataclass -from contextlib import asynccontextmanager -from collections.abc import AsyncIterator - -from mcp.types import ( - JSONRPCMessage, JSONRPCRequest, JSONRPCResponse, JSONRPCError, - InitializeRequest, InitializeResult, ServerInfo, - ListToolsRequest, ListToolsResult, Tool, - CallToolRequest, CallToolResult, TextContent, - ListResourcesRequest, ListResourcesResult, Resource, - ReadResourceRequest, ReadResourceResult, - ListPromptsRequest, ListPromptsResult, Prompt, - GetPromptRequest, GetPromptResult, PromptMessage -) -from mcp.server import Server -from mcp.server.stdio import stdio_server -from mcp.server.session import ServerSession - -logger = logging.getLogger(__name__) - -class LowLevelServer: - """Low-level MCP server with direct protocol access.""" - - def __init__(self, name: str, version: str = "1.0.0"): - self.name = name - self.version = version - - # Protocol handlers - self.request_handlers: Dict[str, Callable] = {} - self.notification_handlers: Dict[str, Callable] = {} - - # Server state - self.initialized = False - self.capabilities = {} - self.client_capabilities = {} - - # Register core handlers - self._register_core_handlers() - - # Custom tool, resource, and prompt registries - self.tools: Dict[str, Callable] = {} - self.resources: Dict[str, Callable] = {} - self.prompts: Dict[str, Callable] = {} - - def _register_core_handlers(self): - """Register core MCP protocol handlers.""" - self.request_handlers.update({ - "initialize": self._handle_initialize, - "tools/list": self._handle_list_tools, - "tools/call": self._handle_call_tool, - "resources/list": self._handle_list_resources, - "resources/read": self._handle_read_resource, - "prompts/list": self._handle_list_prompts, - "prompts/get": self._handle_get_prompt, - }) - - self.notification_handlers.update({ - "initialized": self._handle_initialized, - "progress": self._handle_progress, - }) - - def register_tool(self, name: str, handler: Callable, description: str = "", input_schema: Dict[str, Any] = None): - """Register a tool handler.""" - self.tools[name] = { - 'handler': handler, - 'description': description, - 'input_schema': input_schema or {} - } - - def register_resource(self, uri: str, handler: Callable, name: str = "", description: str = ""): - """Register a resource handler.""" - self.resources[uri] = { - 'handler': handler, - 'name': name or uri, - 'description': description - } - - def register_prompt(self, name: str, handler: Callable, description: str = "", arguments: List[Dict[str, Any]] = None): - """Register a prompt handler.""" - self.prompts[name] = { - 'handler': handler, - 'description': description, - 'arguments': arguments or [] - } - - async def process_message(self, message: JSONRPCMessage) -> Optional[JSONRPCMessage]: - """Process an incoming JSON-RPC message.""" - try: - if isinstance(message, JSONRPCRequest): - return await self._handle_request(message) - elif hasattr(message, 'method'): # Notification - await self._handle_notification(message) - return None - else: - logger.warning(f"Unknown message type: {type(message)}") - return None - - except Exception as e: - logger.exception(f"Error processing message: {e}") - if isinstance(message, JSONRPCRequest): - return JSONRPCResponse( - id=message.id, - error=JSONRPCError( - code=-32603, # Internal error - message=f"Internal server error: {str(e)}" - ) - ) - return None - - async def _handle_request(self, request: JSONRPCRequest) -> JSONRPCResponse: - """Handle a JSON-RPC request.""" - method = request.method - params = request.params or {} - - if method not in self.request_handlers: - return JSONRPCResponse( - id=request.id, - error=JSONRPCError( - code=-32601, # Method not found - message=f"Method not found: {method}" - ) - ) - - try: - handler = self.request_handlers[method] - - # Call handler with proper parameters - if asyncio.iscoroutinefunction(handler): - result = await handler(params) - else: - result = handler(params) - - return JSONRPCResponse(id=request.id, result=result) - - except Exception as e: - logger.exception(f"Error handling request {method}: {e}") - return JSONRPCResponse( - id=request.id, - error=JSONRPCError( - code=-32603, # Internal error - message=str(e) - ) - ) - - async def _handle_notification(self, notification): - """Handle a JSON-RPC notification.""" - method = getattr(notification, 'method', None) - params = getattr(notification, 'params', {}) or {} - - if method in self.notification_handlers: - try: - handler = self.notification_handlers[method] - if asyncio.iscoroutinefunction(handler): - await handler(params) - else: - handler(params) - except Exception as e: - logger.exception(f"Error handling notification {method}: {e}") - else: - logger.warning(f"Unknown notification method: {method}") - - async def _handle_initialize(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Handle initialize request.""" - protocol_version = params.get('protocolVersion') - client_info = params.get('clientInfo', {}) - self.client_capabilities = params.get('capabilities', {}) - - logger.info(f"Initializing server for client: {client_info.get('name', 'Unknown')}") - - # Define server capabilities - self.capabilities = { - "tools": {"listChanged": True} if self.tools else None, - "resources": {"subscribe": True, "listChanged": True} if self.resources else None, - "prompts": {"listChanged": True} if self.prompts else None, - "logging": {}, - } - - # Remove None capabilities - self.capabilities = {k: v for k, v in self.capabilities.items() if v is not None} - - return { - "protocolVersion": protocol_version, - "capabilities": self.capabilities, - "serverInfo": { - "name": self.name, - "version": self.version - } - } - - def _handle_initialized(self, params: Dict[str, Any]): - """Handle initialized notification.""" - self.initialized = True - logger.info("Server initialization completed") - - async def _handle_list_tools(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Handle tools/list request.""" - tools = [] - for name, tool_info in self.tools.items(): - tools.append({ - "name": name, - "description": tool_info['description'], - "inputSchema": tool_info['input_schema'] - }) - - return {"tools": tools} - - async def _handle_call_tool(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Handle tools/call request.""" - name = params.get('name') - arguments = params.get('arguments', {}) - - if name not in self.tools: - raise ValueError(f"Tool not found: {name}") - - tool_info = self.tools[name] - handler = tool_info['handler'] - - try: - # Call the tool handler - if asyncio.iscoroutinefunction(handler): - result = await handler(**arguments) - else: - result = handler(**arguments) - - # Convert result to content format - if isinstance(result, str): - content = [{"type": "text", "text": result}] - elif isinstance(result, dict): - content = [{"type": "text", "text": json.dumps(result, indent=2)}] - elif isinstance(result, list): - content = [{"type": "text", "text": json.dumps(result, indent=2)}] - else: - content = [{"type": "text", "text": str(result)}] - - return { - "content": content, - "isError": False - } - - except Exception as e: - logger.exception(f"Error executing tool {name}: {e}") - return { - "content": [{"type": "text", "text": str(e)}], - "isError": True - } - - async def _handle_list_resources(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Handle resources/list request.""" - resources = [] - for uri, resource_info in self.resources.items(): - resources.append({ - "uri": uri, - "name": resource_info['name'], - "description": resource_info['description'], - "mimeType": "text/plain" # Default, can be customized - }) - - return {"resources": resources} - - async def _handle_read_resource(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Handle resources/read request.""" - uri = params.get('uri') - - if uri not in self.resources: - raise ValueError(f"Resource not found: {uri}") - - resource_info = self.resources[uri] - handler = resource_info['handler'] - - try: - # Call the resource handler - if asyncio.iscoroutinefunction(handler): - result = await handler(uri) - else: - result = handler(uri) - - # Convert result to content format - if isinstance(result, str): - contents = [{"type": "text", "text": result}] - elif isinstance(result, bytes): - contents = [{"type": "blob", "blob": result}] - else: - contents = [{"type": "text", "text": str(result)}] - - return {"contents": contents} - - except Exception as e: - logger.exception(f"Error reading resource {uri}: {e}") - raise - - async def _handle_list_prompts(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Handle prompts/list request.""" - prompts = [] - for name, prompt_info in self.prompts.items(): - prompts.append({ - "name": name, - "description": prompt_info['description'], - "arguments": prompt_info['arguments'] - }) - - return {"prompts": prompts} - - async def _handle_get_prompt(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Handle prompts/get request.""" - name = params.get('name') - arguments = params.get('arguments', {}) - - if name not in self.prompts: - raise ValueError(f"Prompt not found: {name}") - - prompt_info = self.prompts[name] - handler = prompt_info['handler'] - - try: - # Call the prompt handler - if asyncio.iscoroutinefunction(handler): - result = await handler(**arguments) - else: - result = handler(**arguments) - - # Convert result to messages format - if isinstance(result, str): - messages = [{"role": "user", "content": {"type": "text", "text": result}}] - elif isinstance(result, list): - messages = result - elif isinstance(result, dict): - if 'messages' in result: - messages = result['messages'] - else: - messages = [{"role": "user", "content": {"type": "text", "text": json.dumps(result)}}] - else: - messages = [{"role": "user", "content": {"type": "text", "text": str(result)}}] - - return { - "description": prompt_info['description'], - "messages": messages - } - - except Exception as e: - logger.exception(f"Error getting prompt {name}: {e}") - raise - - def _handle_progress(self, params: Dict[str, Any]): - """Handle progress notification.""" - progress_token = params.get('progressToken') - progress = params.get('progress') - total = params.get('total') - - logger.info(f"Progress update: {progress}/{total} (token: {progress_token})") - - async def run_stdio(self): - """Run server with stdio transport.""" - server = Server() - - @server.list_tools() - async def list_tools() -> List[Tool]: - tools = [] - for name, tool_info in self.tools.items(): - tools.append(Tool( - name=name, - description=tool_info['description'], - inputSchema=tool_info['input_schema'] - )) - return tools - - @server.call_tool() - async def call_tool(name: str, arguments: dict) -> List[TextContent]: - if name not in self.tools: - raise ValueError(f"Tool not found: {name}") - - tool_info = self.tools[name] - handler = tool_info['handler'] - - if asyncio.iscoroutinefunction(handler): - result = await handler(**arguments) - else: - result = handler(**arguments) - - return [TextContent(type="text", text=str(result))] - - # Add resource and prompt handlers similarly... - - async with stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - InitializeResult( - protocolVersion="2025-06-18", - capabilities=server.get_capabilities(), - serverInfo=ServerInfo(name=self.name, version=self.version) - ) - ) - -# Usage example -def create_calculator_server(): - """Create a low-level calculator server.""" - server = LowLevelServer("Calculator Server", "1.0.0") - - # Register calculator tools - def add(a: float, b: float) -> float: - """Add two numbers.""" - return a + b - - def multiply(a: float, b: float) -> float: - """Multiply two numbers.""" - return a * b - - def divide(a: float, b: float) -> float: - """Divide two numbers.""" - if b == 0: - raise ValueError("Cannot divide by zero") - return a / b - - # Register tools with schemas - server.register_tool( - "add", - add, - "Add two numbers together", - { - "type": "object", - "properties": { - "a": {"type": "number", "description": "First number"}, - "b": {"type": "number", "description": "Second number"} - }, - "required": ["a", "b"] - } - ) - - server.register_tool( - "multiply", - multiply, - "Multiply two numbers", - { - "type": "object", - "properties": { - "a": {"type": "number", "description": "First number"}, - "b": {"type": "number", "description": "Second number"} - }, - "required": ["a", "b"] - } - ) - - server.register_tool( - "divide", - divide, - "Divide first number by second number", - { - "type": "object", - "properties": { - "a": {"type": "number", "description": "Dividend"}, - "b": {"type": "number", "description": "Divisor"} - }, - "required": ["a", "b"] - } - ) - - # Register a configuration resource - def get_config(uri: str) -> str: - """Get server configuration.""" - if uri == "config://settings": - return json.dumps({ - "precision": 6, - "max_operations": 1000, - "supported_operations": ["add", "multiply", "divide"] - }, indent=2) - return "Configuration not found" - - server.register_resource( - "config://settings", - get_config, - "Server Settings", - "Calculator server configuration" - ) - - # Register a calculation prompt - def math_prompt(operation: str = "add", **kwargs) -> str: - """Generate a math problem prompt.""" - if operation == "add": - return f"Please add the following numbers: {kwargs.get('numbers', [1, 2, 3])}" - elif operation == "multiply": - return f"Please multiply these numbers: {kwargs.get('numbers', [2, 3, 4])}" - else: - return f"Please perform {operation} on the given numbers" - - server.register_prompt( - "math_problem", - math_prompt, - "Generate a math problem", - [ - {"name": "operation", "description": "Type of operation", "required": False}, - {"name": "numbers", "description": "Numbers to use", "required": False} - ] - ) - - return server - -if __name__ == "__main__": - # Create and run the server - calc_server = create_calculator_server() - asyncio.run(calc_server.run_stdio()) -``` - -## Custom transport implementation - -### HTTP transport - -```python -""" -Custom HTTP transport for low-level MCP server. -""" - -import asyncio -import json -from aiohttp import web, WSMsgType -from typing import Dict, Any, Optional - -class HttpTransport: - """HTTP transport for MCP server.""" - - def __init__(self, server: LowLevelServer, host: str = "localhost", port: int = 8000): - self.server = server - self.host = host - self.port = port - self.app = web.Application() - self.sessions: Dict[str, Any] = {} - - # Setup routes - self._setup_routes() - - def _setup_routes(self): - """Setup HTTP routes.""" - self.app.router.add_post('/mcp', self._handle_http_request) - self.app.router.add_get('/mcp/ws', self._handle_websocket) - self.app.router.add_get('/health', self._health_check) - self.app.router.add_get('/', self._index) - - async def _handle_http_request(self, request): - """Handle HTTP POST request.""" - try: - data = await request.json() - - # Convert to JSONRPCRequest - message = self._parse_jsonrpc_message(data) - if not message: - return web.json_response( - {"error": "Invalid JSON-RPC message"}, - status=400 - ) - - # Process message - response = await self.server.process_message(message) - - if response: - return web.json_response(self._serialize_response(response)) - else: - return web.Response(status=204) # No content for notifications - - except json.JSONDecodeError: - return web.json_response( - {"error": "Invalid JSON"}, - status=400 - ) - except Exception as e: - return web.json_response( - {"error": str(e)}, - status=500 - ) - - async def _handle_websocket(self, request): - """Handle WebSocket connection.""" - ws = web.WebSocketResponse() - await ws.prepare(request) - - session_id = id(ws) - self.sessions[session_id] = ws - - try: - async for msg in ws: - if msg.type == WSMsgType.TEXT: - try: - data = json.loads(msg.data) - message = self._parse_jsonrpc_message(data) - - if message: - response = await self.server.process_message(message) - if response: - await ws.send_str(json.dumps(self._serialize_response(response))) - - except Exception as e: - error_response = { - "jsonrpc": "2.0", - "error": {"code": -32603, "message": str(e)}, - "id": None - } - await ws.send_str(json.dumps(error_response)) - - elif msg.type == WSMsgType.ERROR: - print(f'WebSocket error: {ws.exception()}') - break - - finally: - if session_id in self.sessions: - del self.sessions[session_id] - - return ws - - async def _health_check(self, request): - """Health check endpoint.""" - return web.json_response({ - "status": "healthy", - "server": self.server.name, - "version": self.server.version, - "initialized": self.server.initialized - }) - - async def _index(self, request): - """Index page with server info.""" - html = f""" - - - - {self.server.name} - - -

{self.server.name}

-

Version: {self.server.version}

-

Status: {"Initialized" if self.server.initialized else "Not initialized"}

-

Endpoints:

-
    -
  • POST /mcp - JSON-RPC over HTTP
  • -
  • GET /mcp/ws - WebSocket connection
  • -
  • GET /health - Health check
  • -
- - - """ - return web.Response(text=html, content_type='text/html') - - def _parse_jsonrpc_message(self, data: Dict[str, Any]): - """Parse JSON-RPC message from data.""" - if not isinstance(data, dict) or data.get('jsonrpc') != '2.0': - return None - - if 'method' in data: - # Request or notification - return type('JSONRPCMessage', (), { - 'jsonrpc': data['jsonrpc'], - 'method': data['method'], - 'params': data.get('params'), - 'id': data.get('id') - })() - - return None - - def _serialize_response(self, response) -> Dict[str, Any]: - """Serialize response to JSON-RPC format.""" - result = { - "jsonrpc": "2.0", - "id": getattr(response, 'id', None) - } - - if hasattr(response, 'result'): - result["result"] = response.result - elif hasattr(response, 'error'): - result["error"] = { - "code": response.error.code, - "message": response.error.message, - "data": getattr(response.error, 'data', None) - } - - return result - - async def run(self): - """Run the HTTP server.""" - runner = web.AppRunner(self.app) - await runner.setup() - - site = web.TCPSite(runner, self.host, self.port) - await site.start() - - print(f"MCP server running on http://{self.host}:{self.port}") - - try: - await asyncio.Future() # Run forever - except KeyboardInterrupt: - pass - finally: - await runner.cleanup() - -# Usage example -async def run_http_server(): - """Run calculator server with HTTP transport.""" - server = create_calculator_server() - transport = HttpTransport(server, "localhost", 8000) - await transport.run() - -if __name__ == "__main__": - asyncio.run(run_http_server()) -``` - -## Advanced features - -### Custom protocol extensions - -```python -""" -Custom protocol extensions for MCP server. -""" - -from typing import Any, Dict, List, Optional -import time -import uuid - -class ExtendedServer(LowLevelServer): - """MCP server with custom protocol extensions.""" - - def __init__(self, name: str, version: str = "1.0.0"): - super().__init__(name, version) - - # Extension state - self.metrics = {} - self.sessions = {} - - # Register extension handlers - self._register_extensions() - - def _register_extensions(self): - """Register custom protocol extensions.""" - # Custom methods - self.request_handlers.update({ - "server/metrics": self._handle_get_metrics, - "server/status": self._handle_get_status, - "tools/batch": self._handle_batch_tools, - "session/create": self._handle_create_session, - "session/destroy": self._handle_destroy_session, - }) - - # Custom notifications - self.notification_handlers.update({ - "client/heartbeat": self._handle_heartbeat, - "metrics/report": self._handle_metrics_report, - }) - - async def process_message(self, message): - """Enhanced message processing with metrics.""" - start_time = time.time() - method = getattr(message, 'method', 'unknown') - - try: - result = await super().process_message(message) - - # Record success metrics - self._record_metric(method, time.time() - start_time, True) - - return result - - except Exception as e: - # Record error metrics - self._record_metric(method, time.time() - start_time, False) - raise - - def _record_metric(self, method: str, duration: float, success: bool): - """Record operation metrics.""" - if method not in self.metrics: - self.metrics[method] = { - 'count': 0, - 'success_count': 0, - 'error_count': 0, - 'total_duration': 0.0, - 'avg_duration': 0.0, - 'last_called': None - } - - metric = self.metrics[method] - metric['count'] += 1 - metric['total_duration'] += duration - metric['avg_duration'] = metric['total_duration'] / metric['count'] - metric['last_called'] = time.time() - - if success: - metric['success_count'] += 1 - else: - metric['error_count'] += 1 - - async def _handle_get_metrics(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Handle server/metrics request.""" - return { - "metrics": self.metrics, - "server_uptime": time.time() - getattr(self, '_start_time', time.time()), - "active_sessions": len(self.sessions) - } - - async def _handle_get_status(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Handle server/status request.""" - return { - "name": self.name, - "version": self.version, - "initialized": self.initialized, - "capabilities": self.capabilities, - "tools_count": len(self.tools), - "resources_count": len(self.resources), - "prompts_count": len(self.prompts), - "uptime": time.time() - getattr(self, '_start_time', time.time()) - } - - async def _handle_batch_tools(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Handle tools/batch request for executing multiple tools.""" - calls = params.get('calls', []) - results = [] - - for call in calls: - tool_name = call.get('name') - arguments = call.get('arguments', {}) - call_id = call.get('id', str(uuid.uuid4())) - - try: - # Use existing tool call logic - tool_result = await self._handle_call_tool({ - 'name': tool_name, - 'arguments': arguments - }) - - results.append({ - 'id': call_id, - 'result': tool_result, - 'success': True - }) - - except Exception as e: - results.append({ - 'id': call_id, - 'error': str(e), - 'success': False - }) - - return {"results": results} - - async def _handle_create_session(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Handle session/create request.""" - session_id = str(uuid.uuid4()) - session_name = params.get('name', f"Session {session_id[:8]}") - - self.sessions[session_id] = { - 'id': session_id, - 'name': session_name, - 'created_at': time.time(), - 'last_activity': time.time(), - 'context': params.get('context', {}) - } - - return { - "session_id": session_id, - "name": session_name, - "created_at": self.sessions[session_id]['created_at'] - } - - async def _handle_destroy_session(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Handle session/destroy request.""" - session_id = params.get('session_id') - - if session_id in self.sessions: - del self.sessions[session_id] - return {"success": True, "message": f"Session {session_id} destroyed"} - else: - raise ValueError(f"Session not found: {session_id}") - - def _handle_heartbeat(self, params: Dict[str, Any]): - """Handle client/heartbeat notification.""" - client_id = params.get('client_id', 'unknown') - timestamp = params.get('timestamp', time.time()) - - logger.info(f"Heartbeat from client {client_id} at {timestamp}") - - def _handle_metrics_report(self, params: Dict[str, Any]): - """Handle client metrics report.""" - client_metrics = params.get('metrics', {}) - client_id = params.get('client_id', 'unknown') - - logger.info(f"Received metrics from client {client_id}: {client_metrics}") - -# Example with custom extensions -def create_extended_server(): - """Create a server with custom protocol extensions.""" - server = ExtendedServer("Extended Calculator", "2.0.0") - server._start_time = time.time() - - # Add standard calculator tools - server.register_tool( - "add", - lambda a, b: a + b, - "Add two numbers", - { - "type": "object", - "properties": { - "a": {"type": "number"}, - "b": {"type": "number"} - }, - "required": ["a", "b"] - } - ) - - # Add extended tool with session context - async def contextual_calculate(operation: str, numbers: List[float], session_id: str = None) -> Dict[str, Any]: - """Perform calculation with session context.""" - session = None - if session_id and session_id in server.sessions: - session = server.sessions[session_id] - session['last_activity'] = time.time() - - # Perform calculation - if operation == "sum": - result = sum(numbers) - elif operation == "product": - result = 1 - for num in numbers: - result *= num - elif operation == "average": - result = sum(numbers) / len(numbers) if numbers else 0 - else: - raise ValueError(f"Unknown operation: {operation}") - - return { - "result": result, - "operation": operation, - "input_numbers": numbers, - "session_context": session['context'] if session else None - } - - server.register_tool( - "contextual_calculate", - contextual_calculate, - "Perform calculation with session context", - { - "type": "object", - "properties": { - "operation": {"type": "string", "enum": ["sum", "product", "average"]}, - "numbers": {"type": "array", "items": {"type": "number"}}, - "session_id": {"type": "string"} - }, - "required": ["operation", "numbers"] - } - ) - - return server - -if __name__ == "__main__": - # Run extended server - server = create_extended_server() - asyncio.run(server.run_stdio()) -``` - -## Performance optimization - -### Concurrent request handling - -```python -""" -High-performance server with concurrent request handling. -""" - -import asyncio -import time -from typing import Dict, Any, List -from concurrent.futures import ThreadPoolExecutor -from dataclasses import dataclass -import psutil - -@dataclass -class PerformanceConfig: - """Configuration for performance optimizations.""" - max_concurrent_requests: int = 100 - request_timeout: float = 30.0 - thread_pool_size: int = 10 - enable_caching: bool = True - cache_ttl: float = 300.0 # 5 minutes - enable_metrics: bool = True - -class HighPerformanceServer(LowLevelServer): - """High-performance MCP server with optimizations.""" - - def __init__(self, name: str, version: str = "1.0.0", config: PerformanceConfig = None): - super().__init__(name, version) - self.config = config or PerformanceConfig() - - # Performance components - self.thread_pool = ThreadPoolExecutor(max_workers=self.config.thread_pool_size) - self.request_semaphore = asyncio.Semaphore(self.config.max_concurrent_requests) - self.cache: Dict[str, Dict[str, Any]] = {} - self.request_queue = asyncio.Queue() - - # Metrics - self.performance_metrics = { - 'requests_per_second': 0, - 'average_response_time': 0, - 'active_requests': 0, - 'cache_hit_rate': 0, - 'queue_size': 0 - } - - # Start background tasks - self._start_background_tasks() - - def _start_background_tasks(self): - """Start background performance monitoring tasks.""" - if self.config.enable_metrics: - asyncio.create_task(self._metrics_collector()) - if self.config.enable_caching: - asyncio.create_task(self._cache_cleanup()) - - async def process_message(self, message) -> Optional[Any]: - """Process message with performance optimizations.""" - async with self.request_semaphore: - # Add to queue for metrics - await self.request_queue.put(time.time()) - - try: - # Apply timeout - return await asyncio.wait_for( - self._process_message_internal(message), - timeout=self.config.request_timeout - ) - except asyncio.TimeoutError: - logger.warning(f"Request timeout for method: {getattr(message, 'method', 'unknown')}") - if hasattr(message, 'id'): - return self._create_error_response(message.id, -32603, "Request timeout") - return None - finally: - self.performance_metrics['active_requests'] = self.request_semaphore._value - - async def _process_message_internal(self, message): - """Internal message processing with caching.""" - method = getattr(message, 'method', None) - params = getattr(message, 'params', {}) - - # Check cache for read-only operations - if self.config.enable_caching and method in ['tools/list', 'resources/list', 'prompts/list']: - cache_key = f"{method}:{hash(str(params))}" - cached_result = self._get_cached_result(cache_key) - if cached_result: - return cached_result - - # Process message - start_time = time.time() - result = await super().process_message(message) - duration = time.time() - start_time - - # Cache result for read-only operations - if self.config.enable_caching and method in ['tools/list', 'resources/list', 'prompts/list'] and result: - cache_key = f"{method}:{hash(str(params))}" - self._cache_result(cache_key, result, duration) - - return result - - def _get_cached_result(self, cache_key: str) -> Optional[Any]: - """Get result from cache if valid.""" - if cache_key in self.cache: - cache_entry = self.cache[cache_key] - if time.time() - cache_entry['timestamp'] < self.config.cache_ttl: - return cache_entry['result'] - else: - del self.cache[cache_key] - return None - - def _cache_result(self, cache_key: str, result: Any, processing_time: float): - """Cache result with metadata.""" - self.cache[cache_key] = { - 'result': result, - 'timestamp': time.time(), - 'processing_time': processing_time - } - - async def _metrics_collector(self): - """Collect performance metrics.""" - request_times = [] - - while True: - try: - # Calculate requests per second - current_time = time.time() - recent_requests = [] - - # Drain queue and collect recent requests - while not self.request_queue.empty(): - try: - request_time = self.request_queue.get_nowait() - if current_time - request_time < 60: # Last minute - recent_requests.append(request_time) - except asyncio.QueueEmpty: - break - - # Update metrics - self.performance_metrics['requests_per_second'] = len(recent_requests) / 60 - self.performance_metrics['queue_size'] = self.request_queue.qsize() - - # Calculate cache hit rate - total_cache_requests = len(self.cache) - if total_cache_requests > 0: - # Simplified cache hit rate calculation - valid_cache_entries = sum( - 1 for entry in self.cache.values() - if current_time - entry['timestamp'] < self.config.cache_ttl - ) - self.performance_metrics['cache_hit_rate'] = valid_cache_entries / total_cache_requests - - # System metrics - process = psutil.Process() - self.performance_metrics.update({ - 'memory_usage_mb': process.memory_info().rss / 1024 / 1024, - 'cpu_percent': process.cpu_percent(), - 'thread_count': process.num_threads() - }) - - await asyncio.sleep(10) # Update every 10 seconds - - except Exception as e: - logger.exception(f"Error in metrics collector: {e}") - await asyncio.sleep(10) - - async def _cache_cleanup(self): - """Clean up expired cache entries.""" - while True: - try: - current_time = time.time() - expired_keys = [ - key for key, entry in self.cache.items() - if current_time - entry['timestamp'] > self.config.cache_ttl - ] - - for key in expired_keys: - del self.cache[key] - - if expired_keys: - logger.info(f"Cleaned up {len(expired_keys)} expired cache entries") - - await asyncio.sleep(60) # Cleanup every minute - - except Exception as e: - logger.exception(f"Error in cache cleanup: {e}") - await asyncio.sleep(60) - - def _create_error_response(self, request_id: Any, code: int, message: str): - """Create JSON-RPC error response.""" - return type('JSONRPCResponse', (), { - 'id': request_id, - 'error': type('JSONRPCError', (), { - 'code': code, - 'message': message - })() - })() - - # Add performance monitoring tool - async def _handle_performance_stats(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Handle performance/stats request.""" - return { - "performance_metrics": self.performance_metrics, - "config": { - "max_concurrent_requests": self.config.max_concurrent_requests, - "request_timeout": self.config.request_timeout, - "thread_pool_size": self.config.thread_pool_size, - "cache_enabled": self.config.enable_caching, - "cache_ttl": self.config.cache_ttl - }, - "cache_stats": { - "total_entries": len(self.cache), - "memory_usage_estimate": sum( - len(str(entry)) for entry in self.cache.values() - ) - } - } - -# Performance monitoring tool -def add_performance_monitoring(server: HighPerformanceServer): - """Add performance monitoring tools to server.""" - - server.request_handlers["performance/stats"] = server._handle_performance_stats - - def get_system_info() -> Dict[str, Any]: - """Get system information.""" - try: - process = psutil.Process() - return { - "cpu_count": psutil.cpu_count(), - "memory_total_gb": psutil.virtual_memory().total / 1024 / 1024 / 1024, - "memory_available_gb": psutil.virtual_memory().available / 1024 / 1024 / 1024, - "process_memory_mb": process.memory_info().rss / 1024 / 1024, - "process_cpu_percent": process.cpu_percent(), - "open_files": len(process.open_files()), - "connections": len(process.connections()) - } - except Exception as e: - return {"error": str(e)} - - server.register_tool( - "system_info", - get_system_info, - "Get system resource information", - {"type": "object", "properties": {}} - ) - -# Usage example -def create_high_performance_server(): - """Create high-performance server.""" - config = PerformanceConfig( - max_concurrent_requests=200, - request_timeout=60.0, - thread_pool_size=20, - enable_caching=True, - cache_ttl=600.0 - ) - - server = HighPerformanceServer("High Performance Calculator", "3.0.0", config) - add_performance_monitoring(server) - - # Add CPU-intensive tool - def fibonacci(n: int) -> int: - """Calculate Fibonacci number (CPU intensive).""" - if n <= 1: - return n - return fibonacci(n - 1) + fibonacci(n - 2) - - server.register_tool( - "fibonacci", - fibonacci, - "Calculate Fibonacci number (CPU intensive)", - { - "type": "object", - "properties": { - "n": {"type": "integer", "minimum": 0, "maximum": 35} - }, - "required": ["n"] - } - ) - - return server - -if __name__ == "__main__": - server = create_high_performance_server() - asyncio.run(server.run_stdio()) -``` - -## Best practices - -### Architecture guidelines - -- **Separation of concerns** - Keep protocol handling separate from business logic -- **Error boundaries** - Implement comprehensive error handling at each layer -- **Resource management** - Properly manage connections, memory, and file handles -- **Monitoring** - Add metrics and logging for production deployments -- **Testing** - Unit test individual handlers and integration test full workflows - -### Security considerations - -- **Input validation** - Validate all incoming parameters -- **Rate limiting** - Prevent abuse with request rate limits -- **Authentication** - Implement proper authentication for sensitive operations -- **Logging** - Log security events and access attempts -- **Resource limits** - Set limits on computation and memory usage - -### Performance optimization - -- **Async operations** - Use async/await throughout -- **Connection pooling** - Pool database and external service connections -- **Caching** - Cache expensive computations and frequently accessed data -- **Concurrency limits** - Prevent resource exhaustion with semaphores -- **Monitoring** - Track performance metrics and optimize bottlenecks - -## Next steps - -- **[Structured output](structured-output.md)** - Advanced output formatting -- **[Completions](completions.md)** - LLM integration patterns -- **[Authentication](authentication.md)** - Server security implementation -- **[Streamable HTTP](streamable-http.md)** - Modern transport details \ No newline at end of file diff --git a/docs/oauth-clients.md b/docs/oauth-clients.md deleted file mode 100644 index 6d12fa541..000000000 --- a/docs/oauth-clients.md +++ /dev/null @@ -1,971 +0,0 @@ -# OAuth for clients - -Learn how to implement OAuth 2.1 authentication in MCP clients to securely connect to authenticated servers. - -## Overview - -OAuth for MCP clients enables: - -- **Secure authentication** - Industry-standard OAuth 2.1 flows -- **Token management** - Automatic token refresh and storage -- **Multiple providers** - Support for various OAuth providers -- **Credential security** - Secure storage and transmission of credentials - -## Basic OAuth client - -### Simple OAuth client - -```python -""" -MCP client with OAuth 2.1 authentication. -""" - -import asyncio -from mcp import ClientSession -from mcp.client.oauth import OAuthClient, OAuthConfig -from mcp.client.streamable_http import streamablehttp_client - -async def oauth_client_example(): - """Connect to MCP server with OAuth authentication.""" - - # Configure OAuth - oauth_config = OAuthConfig( - client_id="your-client-id", - client_secret="your-client-secret", - authorization_url="https://auth.example.com/oauth/authorize", - token_url="https://auth.example.com/oauth/token", - redirect_uri="http://localhost:8080/callback", - scope="mcp:read mcp:write" - ) - - # Create OAuth client - oauth_client = OAuthClient(oauth_config) - - # Authenticate (will open browser for authorization) - await oauth_client.authenticate() - - # Connect to MCP server with OAuth - server_url = "https://api.example.com/mcp" - - async with streamablehttp_client(server_url) as (read, write, session_info): - # Add OAuth headers to requests - oauth_client.add_auth_headers(session_info.headers) - - async with ClientSession(read, write) as session: - # Initialize with authentication - await session.initialize() - - # Now you can make authenticated requests - tools = await session.list_tools() - print(f"Available tools: {[tool.name for tool in tools.tools]}") - - # Call tools with authentication - result = await session.call_tool("protected_tool", {"data": "test"}) - if result.content: - content = result.content[0] - if hasattr(content, 'text'): - print(f"Result: {content.text}") - -if __name__ == "__main__": - asyncio.run(oauth_client_example()) -``` - -## OAuth configuration - -### Configuration options - -```python -""" -Comprehensive OAuth configuration. -""" - -from mcp.client.oauth import OAuthConfig, OAuthFlow - -# Standard authorization code flow -standard_config = OAuthConfig( - client_id="your-client-id", - client_secret="your-client-secret", - authorization_url="https://auth.example.com/oauth/authorize", - token_url="https://auth.example.com/oauth/token", - redirect_uri="http://localhost:8080/callback", - scope="mcp:read mcp:write mcp:admin", - flow=OAuthFlow.AUTHORIZATION_CODE -) - -# PKCE flow for public clients -pkce_config = OAuthConfig( - client_id="public-client-id", - authorization_url="https://auth.example.com/oauth/authorize", - token_url="https://auth.example.com/oauth/token", - redirect_uri="http://localhost:8080/callback", - scope="mcp:read mcp:write", - flow=OAuthFlow.AUTHORIZATION_CODE_PKCE, - code_challenge_method="S256" -) - -# Client credentials flow for service-to-service -service_config = OAuthConfig( - client_id="service-client-id", - client_secret="service-client-secret", - token_url="https://auth.example.com/oauth/token", - scope="mcp:service", - flow=OAuthFlow.CLIENT_CREDENTIALS -) - -# Device code flow for CLI applications -device_config = OAuthConfig( - client_id="device-client-id", - authorization_url="https://auth.example.com/device/authorize", - token_url="https://auth.example.com/oauth/token", - device_authorization_url="https://auth.example.com/device/code", - scope="mcp:read mcp:write", - flow=OAuthFlow.DEVICE_CODE -) -``` - -### Environment configuration - -Store OAuth credentials securely: - -```bash -# .env file for OAuth configuration -OAUTH_CLIENT_ID=your-client-id -OAUTH_CLIENT_SECRET=your-client-secret -OAUTH_AUTHORIZATION_URL=https://auth.example.com/oauth/authorize -OAUTH_TOKEN_URL=https://auth.example.com/oauth/token -OAUTH_REDIRECT_URI=http://localhost:8080/callback -OAUTH_SCOPE=mcp:read mcp:write -``` - -Load from environment: - -```python -import os -from mcp.client.oauth import OAuthConfig - -def load_oauth_config() -> OAuthConfig: - """Load OAuth configuration from environment.""" - return OAuthConfig( - client_id=os.getenv("OAUTH_CLIENT_ID"), - client_secret=os.getenv("OAUTH_CLIENT_SECRET"), - authorization_url=os.getenv("OAUTH_AUTHORIZATION_URL"), - token_url=os.getenv("OAUTH_TOKEN_URL"), - redirect_uri=os.getenv("OAUTH_REDIRECT_URI"), - scope=os.getenv("OAUTH_SCOPE", "mcp:read") - ) -``` - -## Token management - -### Automatic token refresh - -```python -""" -OAuth client with automatic token refresh. -""" - -import asyncio -import json -from pathlib import Path -from mcp.client.oauth import OAuthClient, TokenStore - -class FileTokenStore(TokenStore): - """Token store that persists tokens to disk.""" - - def __init__(self, token_file: str = ".oauth_tokens.json"): - self.token_file = Path(token_file) - - async def save_tokens(self, tokens: dict): - """Save tokens to file.""" - with open(self.token_file, 'w') as f: - json.dump(tokens, f, indent=2) - - async def load_tokens(self) -> dict | None: - """Load tokens from file.""" - if not self.token_file.exists(): - return None - - try: - with open(self.token_file, 'r') as f: - return json.load(f) - except (json.JSONDecodeError, IOError): - return None - - async def clear_tokens(self): - """Clear stored tokens.""" - if self.token_file.exists(): - self.token_file.unlink() - -class AutoRefreshOAuthClient(OAuthClient): - """OAuth client with automatic token refresh.""" - - def __init__(self, config: OAuthConfig, token_store: TokenStore): - super().__init__(config) - self.token_store = token_store - self._tokens = None - - async def authenticate(self): - """Authenticate with token refresh support.""" - # Try to load existing tokens - self._tokens = await self.token_store.load_tokens() - - if self._tokens: - # Check if tokens are still valid - if await self._are_tokens_valid(): - return - - # Try to refresh tokens - if await self._refresh_tokens(): - return - - # Perform fresh authentication - await super().authenticate() - - # Save new tokens - if self._tokens: - await self.token_store.save_tokens(self._tokens) - - async def _are_tokens_valid(self) -> bool: - """Check if current tokens are valid.""" - if not self._tokens or 'access_token' not in self._tokens: - return False - - # Check expiration if available - if 'expires_at' in self._tokens: - import time - return time.time() < self._tokens['expires_at'] - - return True - - async def _refresh_tokens(self) -> bool: - """Refresh access tokens using refresh token.""" - if not self._tokens or 'refresh_token' not in self._tokens: - return False - - try: - # Make refresh request - refresh_data = { - 'grant_type': 'refresh_token', - 'refresh_token': self._tokens['refresh_token'], - 'client_id': self.config.client_id - } - - if self.config.client_secret: - refresh_data['client_secret'] = self.config.client_secret - - async with aiohttp.ClientSession() as session: - async with session.post( - self.config.token_url, - data=refresh_data - ) as response: - if response.status == 200: - new_tokens = await response.json() - - # Update tokens - self._tokens.update(new_tokens) - - # Calculate expiration - if 'expires_in' in new_tokens: - import time - self._tokens['expires_at'] = time.time() + new_tokens['expires_in'] - - # Save updated tokens - await self.token_store.save_tokens(self._tokens) - return True - - return False - - except Exception: - return False - - def get_auth_headers(self) -> dict[str, str]: - """Get authentication headers for requests.""" - if not self._tokens or 'access_token' not in self._tokens: - raise RuntimeError("Not authenticated") - - return { - 'Authorization': f"Bearer {self._tokens['access_token']}" - } - -# Usage example -async def auto_refresh_example(): - """Example using auto-refresh OAuth client.""" - config = load_oauth_config() - token_store = FileTokenStore() - oauth_client = AutoRefreshOAuthClient(config, token_store) - - # Authenticate (will use stored tokens if valid) - await oauth_client.authenticate() - - # Use client with automatic token refresh - server_url = "https://api.example.com/mcp" - - async with streamablehttp_client(server_url) as (read, write, session_info): - # Add auth headers - session_info.headers.update(oauth_client.get_auth_headers()) - - async with ClientSession(read, write) as session: - await session.initialize() - - # Make authenticated requests - tools = await session.list_tools() - print(f"Available tools: {len(tools.tools)}") - -if __name__ == "__main__": - asyncio.run(auto_refresh_example()) -``` - -## OAuth flows - -### Authorization code flow - -```python -""" -Standard authorization code flow implementation. -""" - -import asyncio -import webbrowser -from urllib.parse import urlparse, parse_qs -from aiohttp import web -import aiohttp - -class AuthorizationCodeFlow: - """OAuth 2.1 Authorization Code Flow.""" - - def __init__(self, config: OAuthConfig): - self.config = config - self.auth_code = None - self.auth_error = None - - async def authenticate(self) -> dict: - """Perform authorization code flow.""" - # Start local callback server - app = web.Application() - app.router.add_get('/callback', self._handle_callback) - - runner = web.AppRunner(app) - await runner.setup() - - # Extract port from redirect URI - parsed_uri = urlparse(self.config.redirect_uri) - port = parsed_uri.port or 8080 - - site = web.TCPSite(runner, 'localhost', port) - await site.start() - - try: - # Generate authorization URL - auth_url = self._build_auth_url() - - # Open browser for user authorization - print(f"Opening browser for authorization: {auth_url}") - webbrowser.open(auth_url) - - # Wait for callback - await self._wait_for_callback() - - if self.auth_error: - raise RuntimeError(f"Authorization failed: {self.auth_error}") - - if not self.auth_code: - raise RuntimeError("No authorization code received") - - # Exchange code for tokens - return await self._exchange_code_for_tokens() - - finally: - await runner.cleanup() - - def _build_auth_url(self) -> str: - """Build authorization URL.""" - from urllib.parse import urlencode - import secrets - - # Generate state for CSRF protection - state = secrets.token_urlsafe(32) - - params = { - 'response_type': 'code', - 'client_id': self.config.client_id, - 'redirect_uri': self.config.redirect_uri, - 'scope': self.config.scope, - 'state': state - } - - return f"{self.config.authorization_url}?{urlencode(params)}" - - async def _handle_callback(self, request): - """Handle OAuth callback.""" - # Extract parameters - code = request.query.get('code') - error = request.query.get('error') - - if error: - self.auth_error = error - else: - self.auth_code = code - - # Return success page - return web.Response( - text="Authorization complete. You can close this window.", - content_type='text/html' - ) - - async def _wait_for_callback(self): - """Wait for OAuth callback.""" - timeout = 300 # 5 minutes - interval = 0.1 - - for _ in range(int(timeout / interval)): - if self.auth_code or self.auth_error: - return - await asyncio.sleep(interval) - - raise TimeoutError("Authorization timeout") - - async def _exchange_code_for_tokens(self) -> dict: - """Exchange authorization code for tokens.""" - token_data = { - 'grant_type': 'authorization_code', - 'code': self.auth_code, - 'redirect_uri': self.config.redirect_uri, - 'client_id': self.config.client_id - } - - if self.config.client_secret: - token_data['client_secret'] = self.config.client_secret - - async with aiohttp.ClientSession() as session: - async with session.post( - self.config.token_url, - data=token_data - ) as response: - if response.status != 200: - error_text = await response.text() - raise RuntimeError(f"Token exchange failed: {error_text}") - - tokens = await response.json() - - # Add expiration timestamp - if 'expires_in' in tokens: - import time - tokens['expires_at'] = time.time() + tokens['expires_in'] - - return tokens -``` - -### Client credentials flow - -```python -""" -Client credentials flow for service-to-service authentication. -""" - -async def client_credentials_flow(config: OAuthConfig) -> dict: - """Perform client credentials flow.""" - if not config.client_secret: - raise ValueError("Client secret required for client credentials flow") - - token_data = { - 'grant_type': 'client_credentials', - 'client_id': config.client_id, - 'client_secret': config.client_secret, - 'scope': config.scope - } - - async with aiohttp.ClientSession() as session: - async with session.post( - config.token_url, - data=token_data - ) as response: - if response.status != 200: - error_text = await response.text() - raise RuntimeError(f"Client credentials flow failed: {error_text}") - - tokens = await response.json() - - # Add expiration timestamp - if 'expires_in' in tokens: - import time - tokens['expires_at'] = time.time() + tokens['expires_in'] - - return tokens - -# Usage example -async def service_auth_example(): - """Service-to-service authentication example.""" - config = OAuthConfig( - client_id="service-client", - client_secret="service-secret", - token_url="https://auth.example.com/oauth/token", - scope="mcp:service", - flow=OAuthFlow.CLIENT_CREDENTIALS - ) - - tokens = await client_credentials_flow(config) - - # Use tokens for authenticated requests - headers = {'Authorization': f"Bearer {tokens['access_token']}"} - - async with streamablehttp_client( - "https://api.example.com/mcp", - headers=headers - ) as (read, write, session_info): - async with ClientSession(read, write) as session: - await session.initialize() - print("Service authenticated successfully") -``` - -### Device code flow - -```python -""" -Device code flow for CLI and limited-input devices. -""" - -async def device_code_flow(config: OAuthConfig) -> dict: - """Perform device code flow.""" - # Request device code - device_data = { - 'client_id': config.client_id, - 'scope': config.scope - } - - async with aiohttp.ClientSession() as session: - # Get device code - async with session.post( - config.device_authorization_url, - data=device_data - ) as response: - if response.status != 200: - error_text = await response.text() - raise RuntimeError(f"Device authorization failed: {error_text}") - - device_response = await response.json() - - # Display user instructions - print(f"Visit: {device_response['verification_uri']}") - print(f"Enter code: {device_response['user_code']}") - print("Waiting for authorization...") - - # Poll for tokens - poll_interval = device_response.get('interval', 5) - expires_in = device_response.get('expires_in', 1800) - device_code = device_response['device_code'] - - import time - start_time = time.time() - - while time.time() - start_time < expires_in: - await asyncio.sleep(poll_interval) - - # Poll token endpoint - poll_data = { - 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', - 'device_code': device_code, - 'client_id': config.client_id - } - - async with session.post( - config.token_url, - data=poll_data - ) as poll_response: - if poll_response.status == 200: - tokens = await poll_response.json() - - # Add expiration timestamp - if 'expires_in' in tokens: - tokens['expires_at'] = time.time() + tokens['expires_in'] - - print("Authorization successful!") - return tokens - - elif poll_response.status == 400: - error_response = await poll_response.json() - error_code = error_response.get('error') - - if error_code == 'authorization_pending': - continue # Keep polling - elif error_code == 'slow_down': - poll_interval += 5 # Increase interval - continue - elif error_code in ['access_denied', 'expired_token']: - raise RuntimeError(f"Authorization failed: {error_code}") - - raise TimeoutError("Device authorization timeout") - -# Usage example -async def device_auth_example(): - """Device code flow example.""" - config = OAuthConfig( - client_id="device-client", - authorization_url="https://auth.example.com/device/authorize", - token_url="https://auth.example.com/oauth/token", - device_authorization_url="https://auth.example.com/device/code", - scope="mcp:read mcp:write", - flow=OAuthFlow.DEVICE_CODE - ) - - tokens = await device_code_flow(config) - - # Use tokens for authenticated requests - headers = {'Authorization': f"Bearer {tokens['access_token']}"} - - async with streamablehttp_client( - "https://api.example.com/mcp", - headers=headers - ) as (read, write, session_info): - async with ClientSession(read, write) as session: - await session.initialize() - print("Device authenticated successfully") -``` - -## Integration examples - -### OAuth with connection pooling - -```python -""" -OAuth client with connection pooling for high-performance applications. -""" - -import asyncio -from dataclasses import dataclass -from typing import Dict, Optional -from contextlib import asynccontextmanager - -@dataclass -class AuthenticatedConnection: - """Connection with OAuth authentication.""" - read_stream: Any - write_stream: Any - session: ClientSession - tokens: Dict[str, Any] - -class OAuthConnectionPool: - """Connection pool with OAuth authentication.""" - - def __init__( - self, - server_url: str, - oauth_config: OAuthConfig, - pool_size: int = 5 - ): - self.server_url = server_url - self.oauth_config = oauth_config - self.pool_size = pool_size - self.available_connections: asyncio.Queue = asyncio.Queue() - self.active_connections: set = set() - self.oauth_client = AutoRefreshOAuthClient( - oauth_config, - FileTokenStore() - ) - - async def initialize(self): - """Initialize the connection pool.""" - # Authenticate once - await self.oauth_client.authenticate() - - # Create initial connections - for _ in range(self.pool_size): - connection = await self._create_connection() - if connection: - await self.available_connections.put(connection) - - async def _create_connection(self) -> Optional[AuthenticatedConnection]: - """Create an authenticated connection.""" - try: - # Get auth headers - headers = self.oauth_client.get_auth_headers() - - # Create connection with auth - read, write, session_info = await streamablehttp_client( - self.server_url, - headers=headers - ).__aenter__() - - # Initialize session - session = ClientSession(read, write) - await session.__aenter__() - await session.initialize() - - return AuthenticatedConnection( - read_stream=read, - write_stream=write, - session=session, - tokens=self.oauth_client._tokens - ) - - except Exception as e: - print(f"Failed to create connection: {e}") - return None - - @asynccontextmanager - async def get_connection(self): - """Get an authenticated connection from the pool.""" - try: - # Get available connection - connection = await asyncio.wait_for( - self.available_connections.get(), - timeout=10.0 - ) - - self.active_connections.add(connection) - yield connection.session - - except asyncio.TimeoutError: - raise RuntimeError("No connections available") - - finally: - # Return connection to pool - if connection in self.active_connections: - self.active_connections.remove(connection) - await self.available_connections.put(connection) - - async def close(self): - """Close all connections in the pool.""" - # Close active connections - for connection in list(self.active_connections): - try: - await connection.session.__aexit__(None, None, None) - except: - pass - - # Close available connections - while not self.available_connections.empty(): - try: - connection = self.available_connections.get_nowait() - await connection.session.__aexit__(None, None, None) - except: - pass - -# Usage example -async def pooled_oauth_example(): - """Example using OAuth connection pool.""" - config = load_oauth_config() - - pool = OAuthConnectionPool( - "https://api.example.com/mcp", - config, - pool_size=3 - ) - - await pool.initialize() - - try: - # Concurrent operations using pool - async def call_tool(tool_name: str, args: dict): - async with pool.get_connection() as session: - result = await session.call_tool(tool_name, args) - return result - - # Execute multiple authenticated calls concurrently - tasks = [ - call_tool("process_data", {"data": f"item_{i}"}) - for i in range(10) - ] - - results = await asyncio.gather(*tasks) - print(f"Processed {len(results)} requests") - - finally: - await pool.close() - -if __name__ == "__main__": - asyncio.run(pooled_oauth_example()) -``` - -## Testing OAuth clients - -### Mock OAuth server - -```python -""" -Mock OAuth server for testing OAuth clients. -""" - -import pytest -import asyncio -from aiohttp import web -import json - -class MockOAuthServer: - """Mock OAuth server for testing.""" - - def __init__(self, port: int = 9999): - self.port = port - self.app = web.Application() - self.runner = None - self.site = None - - # Setup routes - self.app.router.add_post('/oauth/token', self._handle_token) - self.app.router.add_get('/oauth/authorize', self._handle_authorize) - self.app.router.add_post('/device/code', self._handle_device_code) - - # Test data - self.valid_codes = set() - self.valid_tokens = set() - - async def start(self): - """Start the mock server.""" - self.runner = web.AppRunner(self.app) - await self.runner.setup() - self.site = web.TCPSite(self.runner, 'localhost', self.port) - await self.site.start() - - async def stop(self): - """Stop the mock server.""" - if self.runner: - await self.runner.cleanup() - - async def _handle_token(self, request): - """Handle token requests.""" - data = await request.post() - grant_type = data.get('grant_type') - - if grant_type == 'authorization_code': - code = data.get('code') - if code not in self.valid_codes: - return web.json_response( - {'error': 'invalid_grant'}, - status=400 - ) - - # Generate token - token = f"access_token_{len(self.valid_tokens)}" - self.valid_tokens.add(token) - - return web.json_response({ - 'access_token': token, - 'token_type': 'Bearer', - 'expires_in': 3600, - 'refresh_token': f"refresh_token_{len(self.valid_tokens)}" - }) - - elif grant_type == 'client_credentials': - client_id = data.get('client_id') - client_secret = data.get('client_secret') - - if client_id == 'test_client' and client_secret == 'test_secret': - token = f"service_token_{len(self.valid_tokens)}" - self.valid_tokens.add(token) - - return web.json_response({ - 'access_token': token, - 'token_type': 'Bearer', - 'expires_in': 3600 - }) - - return web.json_response({'error': 'unsupported_grant_type'}, status=400) - - async def _handle_authorize(self, request): - """Handle authorization requests.""" - # Generate and store auth code - auth_code = f"auth_code_{len(self.valid_codes)}" - self.valid_codes.add(auth_code) - - # Redirect with code - redirect_uri = request.query.get('redirect_uri') - state = request.query.get('state', '') - - redirect_url = f"{redirect_uri}?code={auth_code}&state={state}" - return web.Response( - status=302, - headers={'Location': redirect_url} - ) - - async def _handle_device_code(self, request): - """Handle device code requests.""" - return web.json_response({ - 'device_code': 'test_device_code', - 'user_code': 'TEST123', - 'verification_uri': f'http://localhost:{self.port}/device/verify', - 'verification_uri_complete': f'http://localhost:{self.port}/device/verify?code=TEST123', - 'expires_in': 1800, - 'interval': 1 - }) - -# Test fixtures -@pytest.fixture -async def mock_oauth_server(): - """Pytest fixture for mock OAuth server.""" - server = MockOAuthServer() - await server.start() - yield server - await server.stop() - -@pytest.mark.asyncio -async def test_oauth_client_credentials(mock_oauth_server): - """Test client credentials flow.""" - config = OAuthConfig( - client_id="test_client", - client_secret="test_secret", - token_url=f"http://localhost:{mock_oauth_server.port}/oauth/token", - flow=OAuthFlow.CLIENT_CREDENTIALS - ) - - tokens = await client_credentials_flow(config) - - assert 'access_token' in tokens - assert tokens['token_type'] == 'Bearer' - assert 'expires_in' in tokens - -@pytest.mark.asyncio -async def test_token_refresh(): - """Test automatic token refresh.""" - config = load_oauth_config() - token_store = FileTokenStore(".test_tokens.json") - - # Create client with mock tokens - client = AutoRefreshOAuthClient(config, token_store) - - # Test token validation and refresh logic - expired_tokens = { - 'access_token': 'expired_token', - 'refresh_token': 'valid_refresh', - 'expires_at': time.time() - 3600 # Expired 1 hour ago - } - - await token_store.save_tokens(expired_tokens) - - # This should trigger token refresh - await client.authenticate() - - # Cleanup - await token_store.clear_tokens() -``` - -## Best practices - -### Security guidelines - -- **Store secrets securely** - Use environment variables or secure vaults -- **Validate tokens** - Always validate token expiration and scope -- **Use PKCE** - Enable PKCE for public clients -- **Rotate tokens** - Implement proper token refresh -- **Secure storage** - Encrypt stored tokens when possible - -### Performance optimization - -- **Connection pooling** - Reuse authenticated connections -- **Token caching** - Cache valid tokens to avoid re-authentication -- **Async operations** - Use async/await for all OAuth operations -- **Batch requests** - Group multiple operations when possible -- **Monitor expiration** - Proactively refresh tokens before expiration - -### Error handling - -- **Retry logic** - Implement exponential backoff for token refresh -- **Graceful degradation** - Handle authentication failures gracefully -- **Logging** - Log authentication events for debugging -- **User feedback** - Provide clear error messages to users -- **Fallback strategies** - Have backup authentication methods - -## Next steps - -- **[Display utilities](display-utilities.md)** - UI helpers for OAuth flows -- **[Parsing results](parsing-results.md)** - Handle authenticated responses -- **[Writing clients](writing-clients.md)** - General client development patterns -- **[Authentication](authentication.md)** - Server-side authentication implementation \ No newline at end of file diff --git a/docs/parsing-results.md b/docs/parsing-results.md deleted file mode 100644 index 078a97925..000000000 --- a/docs/parsing-results.md +++ /dev/null @@ -1,1333 +0,0 @@ -# Parsing results - -Learn how to effectively parse and process complex results from MCP tools, resources, and prompts in your client applications. - -## Overview - -Result parsing enables: - -- **Structured data extraction** - Extract meaningful data from various response formats -- **Type-safe processing** - Validate and convert data to expected types -- **Error handling** - Gracefully handle malformed or unexpected responses -- **Content transformation** - Convert between different data formats - -## Basic result parsing - -### Tool result parsing - -```python -""" -Basic tool result parsing and validation. -""" - -from typing import Any, Dict, List, Optional, Union -from dataclasses import dataclass -import json -import re - -@dataclass -class ParsedToolResult: - """Structured representation of a tool result.""" - success: bool - content: List[str] - structured_data: Optional[Dict[str, Any]] = None - error_message: Optional[str] = None - metadata: Optional[Dict[str, Any]] = None - -class ToolResultParser: - """Parser for MCP tool results.""" - - def __init__(self): - self.content_extractors = { - 'text': self._extract_text_content, - 'json': self._extract_json_content, - 'data': self._extract_binary_content, - 'image': self._extract_image_content - } - - def parse_result(self, result) -> ParsedToolResult: - """Parse a tool result into structured format.""" - if not result: - return ParsedToolResult( - success=False, - content=[], - error_message="Empty result" - ) - - # Check for error status - is_error = getattr(result, 'isError', False) - - # Extract content - content_items = [] - structured_data = None - - if hasattr(result, 'content') and result.content: - for item in result.content: - content_type = self._determine_content_type(item) - extractor = self.content_extractors.get(content_type, self._extract_text_content) - - extracted = extractor(item) - if extracted: - content_items.append(extracted) - - # Extract structured content - if hasattr(result, 'structuredContent') and result.structuredContent: - structured_data = self._parse_structured_content(result.structuredContent) - - return ParsedToolResult( - success=not is_error, - content=content_items, - structured_data=structured_data, - error_message=content_items[0] if is_error and content_items else None - ) - - def _determine_content_type(self, item) -> str: - """Determine the type of content item.""" - if hasattr(item, 'text'): - return 'text' - elif hasattr(item, 'data'): - mime_type = getattr(item, 'mimeType', '') - if mime_type.startswith('image/'): - return 'image' - else: - return 'data' - else: - return 'text' - - def _extract_text_content(self, item) -> str: - """Extract text content from item.""" - if hasattr(item, 'text'): - return item.text - else: - return str(item) - - def _extract_json_content(self, item) -> str: - """Extract and validate JSON content.""" - text = self._extract_text_content(item) - try: - # Validate JSON - json.loads(text) - return text - except json.JSONDecodeError: - return text # Return as-is if not valid JSON - - def _extract_binary_content(self, item) -> str: - """Extract binary content information.""" - if hasattr(item, 'data'): - size = len(item.data) - mime_type = getattr(item, 'mimeType', 'application/octet-stream') - return f"Binary data: {size} bytes ({mime_type})" - return str(item) - - def _extract_image_content(self, item) -> str: - """Extract image content information.""" - if hasattr(item, 'data'): - size = len(item.data) - mime_type = getattr(item, 'mimeType', 'image/unknown') - return f"Image: {size} bytes ({mime_type})" - return str(item) - - def _parse_structured_content(self, structured) -> Dict[str, Any]: - """Parse structured content.""" - if isinstance(structured, dict): - return structured - elif isinstance(structured, str): - try: - return json.loads(structured) - except json.JSONDecodeError: - return {"raw": structured} - else: - return {"raw": str(structured)} - -# Usage example -async def basic_parsing_example(): - """Example of basic result parsing.""" - parser = ToolResultParser() - - # Simulate calling an MCP tool - async with streamablehttp_client("http://localhost:8000/mcp") as (read, write, _): - async with ClientSession(read, write) as session: - await session.initialize() - - # Call a tool - result = await session.call_tool("calculate", {"expression": "2 + 3"}) - - # Parse the result - parsed = parser.parse_result(result) - - print(f"Success: {parsed.success}") - print(f"Content: {parsed.content}") - if parsed.structured_data: - print(f"Structured: {parsed.structured_data}") - if parsed.error_message: - print(f"Error: {parsed.error_message}") - -if __name__ == "__main__": - import asyncio - asyncio.run(basic_parsing_example()) -``` - -## Advanced content extraction - -### Multi-format content parser - -```python -""" -Advanced content parser supporting multiple formats. -""" - -import base64 -import csv -import xml.etree.ElementTree as ET -from typing import Any, Dict, List, Optional, Tuple -from io import StringIO -import re -from dataclasses import dataclass - -@dataclass -class ExtractedContent: - """Represents extracted content with metadata.""" - content: Any - format: str - confidence: float - metadata: Dict[str, Any] - -class ContentExtractor: - """Advanced content extraction from MCP results.""" - - def __init__(self): - self.format_detectors = { - 'json': self._detect_json, - 'xml': self._detect_xml, - 'csv': self._detect_csv, - 'html': self._detect_html, - 'markdown': self._detect_markdown, - 'base64': self._detect_base64, - 'url': self._detect_url, - 'email': self._detect_email, - 'phone': self._detect_phone, - 'number': self._detect_number, - 'table': self._detect_table - } - - self.extractors = { - 'json': self._extract_json, - 'xml': self._extract_xml, - 'csv': self._extract_csv, - 'html': self._extract_html, - 'markdown': self._extract_markdown, - 'base64': self._extract_base64, - 'table': self._extract_table, - 'number': self._extract_number - } - - def extract_content(self, text: str) -> List[ExtractedContent]: - """Extract all recognizable content formats from text.""" - results = [] - - for format_name, detector in self.format_detectors.items(): - confidence = detector(text) - if confidence > 0.5: # Confidence threshold - extractor = self.extractors.get(format_name) - if extractor: - try: - content, metadata = extractor(text) - results.append(ExtractedContent( - content=content, - format=format_name, - confidence=confidence, - metadata=metadata - )) - except Exception as e: - # Log extraction error but continue - pass - - # Sort by confidence - results.sort(key=lambda x: x.confidence, reverse=True) - return results - - def _detect_json(self, text: str) -> float: - """Detect JSON content.""" - text = text.strip() - if (text.startswith('{') and text.endswith('}')) or \ - (text.startswith('[') and text.endswith(']')): - try: - json.loads(text) - return 0.95 - except json.JSONDecodeError: - return 0.1 - return 0.0 - - def _detect_xml(self, text: str) -> float: - """Detect XML content.""" - text = text.strip() - if text.startswith('<') and text.endswith('>'): - try: - ET.fromstring(text) - return 0.9 - except ET.ParseError: - return 0.1 - return 0.0 - - def _detect_csv(self, text: str) -> float: - """Detect CSV content.""" - lines = text.strip().split('\\n') - if len(lines) < 2: - return 0.0 - - # Check for consistent delimiter usage - delimiters = [',', ';', '\\t', '|'] - for delimiter in delimiters: - first_count = lines[0].count(delimiter) - if first_count > 0: - consistent = all( - line.count(delimiter) == first_count - for line in lines[1:3] # Check first few lines - ) - if consistent: - return 0.8 - - return 0.0 - - def _detect_html(self, text: str) -> float: - """Detect HTML content.""" - html_tags = re.findall(r'<[^>]+>', text) - if len(html_tags) > 0: - # Check for common HTML tags - common_tags = ['html', 'body', 'div', 'p', 'span', 'a', 'table', 'tr', 'td'] - tag_score = sum(1 for tag in html_tags if any(ct in tag.lower() for ct in common_tags)) - return min(0.9, tag_score / len(html_tags)) - return 0.0 - - def _detect_markdown(self, text: str) -> float: - """Detect Markdown content.""" - markdown_patterns = [ - r'^#{1,6} ', # Headers - r'\\*\\*.*?\\*\\*', # Bold - r'\\*.*?\\*', # Italic - r'`.*?`', # Code - r'^- ', # List items - r'^\\d+\\. ', # Numbered lists - r'\\[.*?\\]\\(.*?\\)' # Links - ] - - score = 0 - for pattern in markdown_patterns: - if re.search(pattern, text, re.MULTILINE): - score += 0.2 - - return min(0.9, score) - - def _detect_base64(self, text: str) -> float: - """Detect Base64 encoded content.""" - text = text.strip() - if len(text) % 4 == 0 and re.match(r'^[A-Za-z0-9+/]*={0,2}$', text): - try: - decoded = base64.b64decode(text) - # Check if decoded content looks valid - if len(decoded) > 0: - return 0.8 - except Exception: - pass - return 0.0 - - def _detect_url(self, text: str) -> float: - """Detect URL content.""" - url_pattern = r'https?://[^\\s]+' - urls = re.findall(url_pattern, text) - if urls: - return min(0.9, len(urls) * 0.3) - return 0.0 - - def _detect_email(self, text: str) -> float: - """Detect email addresses.""" - email_pattern = r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}' - emails = re.findall(email_pattern, text) - if emails: - return min(0.9, len(emails) * 0.4) - return 0.0 - - def _detect_phone(self, text: str) -> float: - """Detect phone numbers.""" - phone_patterns = [ - r'\\+?1?[-.\\s]?\\(?[0-9]{3}\\)?[-.\\s]?[0-9]{3}[-.\\s]?[0-9]{4}', # US format - r'\\+?[0-9]{1,4}[-.\\s]?[0-9]{3,4}[-.\\s]?[0-9]{3,4}[-.\\s]?[0-9]{3,4}' # International - ] - - for pattern in phone_patterns: - if re.search(pattern, text): - return 0.7 - return 0.0 - - def _detect_number(self, text: str) -> float: - """Detect numeric content.""" - # Remove whitespace and check if it's a number - clean_text = text.strip() - try: - float(clean_text) - return 0.8 - except ValueError: - # Check for numbers with units or formatting - number_pattern = r'[0-9.,]+' - numbers = re.findall(number_pattern, text) - if numbers and len(''.join(numbers)) / len(text) > 0.5: - return 0.6 - return 0.0 - - def _detect_table(self, text: str) -> float: - """Detect tabular data.""" - lines = text.strip().split('\\n') - if len(lines) < 2: - return 0.0 - - # Look for consistent column alignment - pipe_tables = all('|' in line for line in lines[:3]) - if pipe_tables: - return 0.8 - - # Look for whitespace-separated columns - consistent_spacing = True - first_parts = lines[0].split() - for line in lines[1:3]: - if len(line.split()) != len(first_parts): - consistent_spacing = False - break - - if consistent_spacing and len(first_parts) > 1: - return 0.7 - - return 0.0 - - def _extract_json(self, text: str) -> Tuple[Dict[str, Any], Dict[str, Any]]: - """Extract and parse JSON content.""" - data = json.loads(text.strip()) - metadata = { - 'keys': list(data.keys()) if isinstance(data, dict) else None, - 'length': len(data) if isinstance(data, (list, dict)) else None, - 'type': type(data).__name__ - } - return data, metadata - - def _extract_xml(self, text: str) -> Tuple[ET.Element, Dict[str, Any]]: - """Extract and parse XML content.""" - root = ET.fromstring(text.strip()) - metadata = { - 'root_tag': root.tag, - 'attributes': root.attrib, - 'children_count': len(list(root)), - 'text_content': root.text - } - return root, metadata - - def _extract_csv(self, text: str) -> Tuple[List[List[str]], Dict[str, Any]]: - """Extract and parse CSV content.""" - # Try different delimiters - delimiters = [',', ';', '\\t', '|'] - - for delimiter in delimiters: - try: - reader = csv.reader(StringIO(text), delimiter=delimiter) - rows = list(reader) - if len(rows) > 1 and len(rows[0]) > 1: - metadata = { - 'delimiter': delimiter, - 'rows': len(rows), - 'columns': len(rows[0]), - 'headers': rows[0] if rows else None - } - return rows, metadata - except Exception: - continue - - # Fallback: split by lines and whitespace - lines = text.strip().split('\\n') - rows = [line.split() for line in lines] - metadata = { - 'delimiter': 'whitespace', - 'rows': len(rows), - 'columns': len(rows[0]) if rows else 0 - } - return rows, metadata - - def _extract_html(self, text: str) -> Tuple[str, Dict[str, Any]]: - """Extract HTML content and metadata.""" - # Simple HTML parsing - extract text and tags - text_content = re.sub(r'<[^>]+>', '', text) - tags = re.findall(r'<([^>\\s]+)', text) - - metadata = { - 'tags': list(set(tags)), - 'tag_count': len(tags), - 'text_length': len(text_content), - 'has_links': 'href=' in text, - 'has_images': ' Tuple[str, Dict[str, Any]]: - """Extract Markdown content and structure.""" - headers = re.findall(r'^(#{1,6}) (.+)$', text, re.MULTILINE) - links = re.findall(r'\\[([^\\]]+)\\]\\(([^)]+)\\)', text) - code_blocks = re.findall(r'```([^`]+)```', text) - - metadata = { - 'headers': [(len(h[0]), h[1]) for h in headers], - 'links': [{'text': l[0], 'url': l[1]} for l in links], - 'code_blocks': len(code_blocks), - 'has_tables': '|' in text and '---' in text - } - return text, metadata - - def _extract_base64(self, text: str) -> Tuple[bytes, Dict[str, Any]]: - """Extract Base64 decoded content.""" - decoded = base64.b64decode(text.strip()) - - # Try to determine content type - content_type = 'binary' - if decoded.startswith(b'\\x89PNG'): - content_type = 'image/png' - elif decoded.startswith(b'\\xff\\xd8\\xff'): - content_type = 'image/jpeg' - elif decoded.startswith(b'%PDF'): - content_type = 'application/pdf' - - metadata = { - 'size': len(decoded), - 'content_type': content_type, - 'encoded_size': len(text) - } - return decoded, metadata - - def _extract_table(self, text: str) -> Tuple[List[List[str]], Dict[str, Any]]: - """Extract tabular data.""" - lines = text.strip().split('\\n') - - if '|' in text: - # Pipe-separated table - rows = [] - for line in lines: - if '|' in line: - cells = [cell.strip() for cell in line.split('|')] - # Remove empty cells at start/end - if cells and not cells[0]: - cells = cells[1:] - if cells and not cells[-1]: - cells = cells[:-1] - if cells: - rows.append(cells) - else: - # Whitespace-separated table - rows = [line.split() for line in lines if line.strip()] - - metadata = { - 'rows': len(rows), - 'columns': len(rows[0]) if rows else 0, - 'format': 'pipe' if '|' in text else 'whitespace' - } - return rows, metadata - - def _extract_number(self, text: str) -> Tuple[float, Dict[str, Any]]: - """Extract numeric value.""" - # Clean and parse number - clean_text = re.sub(r'[^0-9.-]', '', text.strip()) - - try: - if '.' in clean_text: - value = float(clean_text) - else: - value = int(clean_text) - except ValueError: - value = 0.0 - - # Extract unit if present - unit_match = re.search(r'([a-zA-Z%]+)\\s*$', text.strip()) - unit = unit_match.group(1) if unit_match else None - - metadata = { - 'original_text': text, - 'unit': unit, - 'type': 'float' if isinstance(value, float) else 'int' - } - return value, metadata - -# Usage example -def content_extraction_example(): - """Example of advanced content extraction.""" - extractor = ContentExtractor() - - # Sample mixed content - sample_text = ''' - Here's some JSON data: {"name": "John", "age": 30, "city": "New York"} - - And here's a CSV table: - Name,Age,City - Alice,25,Boston - Bob,30,Chicago - - Contact: john.doe@example.com or call +1-555-123-4567 - - Visit: https://example.com for more info - ''' - - # Extract all content formats - extracted = extractor.extract_content(sample_text) - - print("Extracted content:") - for item in extracted: - print(f"Format: {item.format} (confidence: {item.confidence:.2f})") - print(f"Content: {item.content}") - print(f"Metadata: {item.metadata}") - print("---") - -if __name__ == "__main__": - content_extraction_example() -``` - -## Type-safe result handling - -### Pydantic models for results - -```python -""" -Type-safe result handling using Pydantic models. -""" - -from pydantic import BaseModel, Field, validator -from typing import Any, Dict, List, Optional, Union, Literal -from datetime import datetime -import json - -class ContentItem(BaseModel): - """Base class for content items.""" - type: str - raw_content: str - -class TextContent(ContentItem): - """Text content item.""" - type: Literal["text"] = "text" - text: str - - @validator('text', pre=True) - def extract_text(cls, v, values): - if isinstance(v, str): - return v - return values.get('raw_content', str(v)) - -class JsonContent(ContentItem): - """JSON content item.""" - type: Literal["json"] = "json" - data: Dict[str, Any] - - @validator('data', pre=True) - def parse_json(cls, v, values): - if isinstance(v, dict): - return v - if isinstance(v, str): - try: - return json.loads(v) - except json.JSONDecodeError: - return {"raw": v} - return {"raw": str(v)} - -class BinaryContent(ContentItem): - """Binary content item.""" - type: Literal["binary"] = "binary" - size: int - mime_type: str = "application/octet-stream" - - @validator('size', pre=True) - def calculate_size(cls, v, values): - raw = values.get('raw_content', '') - if hasattr(raw, '__len__'): - return len(raw) - return 0 - -class TableContent(ContentItem): - """Table content item.""" - type: Literal["table"] = "table" - headers: List[str] - rows: List[List[str]] - - @validator('headers', 'rows', pre=True) - def parse_table(cls, v, values, field): - raw = values.get('raw_content', '') - if isinstance(raw, str): - lines = raw.strip().split('\\n') - if lines: - headers = lines[0].split(',') - rows = [line.split(',') for line in lines[1:]] - if field.name == 'headers': - return headers - else: - return rows - return v if isinstance(v, list) else [] - -class NumericContent(ContentItem): - """Numeric content item.""" - type: Literal["number"] = "number" - value: float - unit: Optional[str] = None - - @validator('value', pre=True) - def parse_number(cls, v, values): - if isinstance(v, (int, float)): - return float(v) - if isinstance(v, str): - # Extract number from string - import re - match = re.search(r'([+-]?\\d*\\.?\\d+)', v) - if match: - return float(match.group(1)) - return 0.0 - -class ErrorContent(ContentItem): - """Error content item.""" - type: Literal["error"] = "error" - message: str - code: Optional[str] = None - details: Optional[Dict[str, Any]] = None - -# Result models -class ToolResult(BaseModel): - """Typed tool execution result.""" - tool_name: str - success: bool - timestamp: datetime = Field(default_factory=datetime.now) - content: List[Union[TextContent, JsonContent, BinaryContent, TableContent, NumericContent, ErrorContent]] - structured_data: Optional[Dict[str, Any]] = None - execution_time: Optional[float] = None - metadata: Dict[str, Any] = Field(default_factory=dict) - -class ResourceResult(BaseModel): - """Typed resource read result.""" - uri: str - success: bool - timestamp: datetime = Field(default_factory=datetime.now) - content: List[Union[TextContent, JsonContent, BinaryContent]] - mime_type: Optional[str] = None - size: Optional[int] = None - metadata: Dict[str, Any] = Field(default_factory=dict) - -class PromptResult(BaseModel): - """Typed prompt result.""" - prompt_name: str - description: Optional[str] = None - messages: List[Dict[str, str]] - arguments: Dict[str, Any] = Field(default_factory=dict) - timestamp: datetime = Field(default_factory=datetime.now) - -# Parser for type-safe results -class TypeSafeParser: - """Parser that creates type-safe result objects.""" - - def __init__(self): - self.content_extractors = ContentExtractor() - - def parse_tool_result(self, tool_name: str, raw_result, execution_time: float = None) -> ToolResult: - """Parse raw tool result into typed model.""" - success = not getattr(raw_result, 'isError', False) - content_items = [] - - if hasattr(raw_result, 'content') and raw_result.content: - for item in raw_result.content: - content_items.extend(self._parse_content_item(item)) - - structured_data = None - if hasattr(raw_result, 'structuredContent') and raw_result.structuredContent: - structured_data = raw_result.structuredContent - if isinstance(structured_data, str): - try: - structured_data = json.loads(structured_data) - except json.JSONDecodeError: - pass - - return ToolResult( - tool_name=tool_name, - success=success, - content=content_items, - structured_data=structured_data, - execution_time=execution_time - ) - - def parse_resource_result(self, uri: str, raw_result) -> ResourceResult: - """Parse raw resource result into typed model.""" - content_items = [] - total_size = 0 - mime_type = None - - if hasattr(raw_result, 'contents') and raw_result.contents: - for item in raw_result.contents: - items = self._parse_content_item(item) - content_items.extend(items) - - # Extract metadata - if hasattr(item, 'data') and item.data: - total_size += len(item.data) - if hasattr(item, 'mimeType'): - mime_type = item.mimeType - - return ResourceResult( - uri=uri, - success=True, # If we got here, it succeeded - content=content_items, - mime_type=mime_type, - size=total_size - ) - - def parse_prompt_result(self, prompt_name: str, raw_result, arguments: Dict[str, Any] = None) -> PromptResult: - """Parse raw prompt result into typed model.""" - messages = [] - description = None - - if hasattr(raw_result, 'description'): - description = raw_result.description - - if hasattr(raw_result, 'messages') and raw_result.messages: - for msg in raw_result.messages: - if hasattr(msg, 'role') and hasattr(msg, 'content'): - content_text = msg.content.text if hasattr(msg.content, 'text') else str(msg.content) - messages.append({ - 'role': msg.role, - 'content': content_text - }) - - return PromptResult( - prompt_name=prompt_name, - description=description, - messages=messages, - arguments=arguments or {} - ) - - def _parse_content_item(self, item) -> List[Union[TextContent, JsonContent, BinaryContent, TableContent, NumericContent, ErrorContent]]: - """Parse a single content item into typed content.""" - raw_content = "" - - if hasattr(item, 'text'): - raw_content = item.text - elif hasattr(item, 'data'): - raw_content = item.data - else: - raw_content = str(item) - - # Extract different content types - extracted = self.content_extractors.extract_content(str(raw_content)) - - result = [] - for extract in extracted[:3]: # Limit to top 3 matches - try: - if extract.format == 'json': - result.append(JsonContent( - raw_content=raw_content, - data=extract.content - )) - elif extract.format == 'table': - if len(extract.content) > 0: - headers = extract.content[0] - rows = extract.content[1:] if len(extract.content) > 1 else [] - result.append(TableContent( - raw_content=raw_content, - headers=headers, - rows=rows - )) - elif extract.format == 'number': - result.append(NumericContent( - raw_content=raw_content, - value=extract.content, - unit=extract.metadata.get('unit') - )) - elif extract.format == 'base64': - result.append(BinaryContent( - raw_content=raw_content, - size=extract.metadata.get('size', 0), - mime_type=extract.metadata.get('content_type', 'application/octet-stream') - )) - except Exception: - # Fall back to text content - pass - - # Always include text representation - if not result or extracted[0].confidence < 0.8: - result.append(TextContent( - raw_content=raw_content, - text=str(raw_content) - )) - - return result - -# Usage example -async def type_safe_parsing_example(): - """Example of type-safe result parsing.""" - parser = TypeSafeParser() - - # Mock tool result - class MockResult: - def __init__(self, is_error=False): - self.isError = is_error - self.content = [MockContent()] - self.structuredContent = {"result": 42, "status": "success"} - - class MockContent: - def __init__(self): - self.text = '{"name": "John", "age": 30, "scores": [85, 92, 78]}' - - # Parse result - raw_result = MockResult() - parsed = parser.parse_tool_result("data_processor", raw_result, execution_time=0.5) - - print(f"Tool: {parsed.tool_name}") - print(f"Success: {parsed.success}") - print(f"Timestamp: {parsed.timestamp}") - print(f"Execution time: {parsed.execution_time}s") - - for i, content in enumerate(parsed.content): - print(f"\\nContent {i+1}:") - print(f" Type: {content.type}") - if isinstance(content, JsonContent): - print(f" Data: {content.data}") - elif isinstance(content, TextContent): - print(f" Text: {content.text}") - elif isinstance(content, NumericContent): - print(f" Value: {content.value} {content.unit or ''}") - - if parsed.structured_data: - print(f"\\nStructured data: {parsed.structured_data}") - -if __name__ == "__main__": - import asyncio - asyncio.run(type_safe_parsing_example()) -``` - -## Error handling and validation - -### Robust error handling - -```python -""" -Robust error handling for MCP result parsing. -""" - -from typing import Any, Dict, List, Optional, Tuple -from enum import Enum -import logging -from dataclasses import dataclass - -class ParseErrorType(Enum): - """Types of parsing errors.""" - INVALID_FORMAT = "invalid_format" - MISSING_CONTENT = "missing_content" - TYPE_MISMATCH = "type_mismatch" - VALIDATION_FAILED = "validation_failed" - UNKNOWN_ERROR = "unknown_error" - -@dataclass -class ParseError: - """Represents a parsing error.""" - error_type: ParseErrorType - message: str - field: Optional[str] = None - raw_value: Optional[Any] = None - suggestions: List[str] = None - -class ParseResult: - """Result of parsing operation with error handling.""" - - def __init__(self, success: bool = True): - self.success = success - self.data: Optional[Any] = None - self.errors: List[ParseError] = [] - self.warnings: List[str] = [] - - def add_error(self, error_type: ParseErrorType, message: str, field: str = None, raw_value: Any = None, suggestions: List[str] = None): - """Add a parsing error.""" - self.success = False - self.errors.append(ParseError( - error_type=error_type, - message=message, - field=field, - raw_value=raw_value, - suggestions=suggestions or [] - )) - - def add_warning(self, message: str): - """Add a parsing warning.""" - self.warnings.append(message) - - def set_data(self, data: Any): - """Set the parsed data.""" - self.data = data - -class RobustParser: - """Parser with comprehensive error handling.""" - - def __init__(self): - self.logger = logging.getLogger(__name__) - self.validation_rules = {} - - def register_validator(self, field_name: str, validator_func): - """Register a custom validator for a field.""" - self.validation_rules[field_name] = validator_func - - def parse_with_validation(self, data: Any, expected_schema: Dict[str, Any]) -> ParseResult: - """Parse data with validation against expected schema.""" - result = ParseResult() - - if not data: - result.add_error( - ParseErrorType.MISSING_CONTENT, - "No data provided", - suggestions=["Ensure the tool returned content"] - ) - return result - - try: - # Convert to dict if needed - if isinstance(data, str): - try: - data = json.loads(data) - except json.JSONDecodeError as e: - result.add_error( - ParseErrorType.INVALID_FORMAT, - f"Invalid JSON: {e}", - raw_value=data, - suggestions=["Check JSON syntax", "Verify proper escaping"] - ) - return result - - if not isinstance(data, dict): - result.add_error( - ParseErrorType.TYPE_MISMATCH, - f"Expected dict, got {type(data).__name__}", - raw_value=data - ) - return result - - # Validate schema - validated_data = self._validate_schema(data, expected_schema, result) - result.set_data(validated_data) - - except Exception as e: - self.logger.exception("Unexpected error during parsing") - result.add_error( - ParseErrorType.UNKNOWN_ERROR, - f"Unexpected error: {e}", - suggestions=["Check data format", "Verify tool output"] - ) - - return result - - def _validate_schema(self, data: Dict[str, Any], schema: Dict[str, Any], result: ParseResult) -> Dict[str, Any]: - """Validate data against schema.""" - validated = {} - - # Check required fields - required_fields = schema.get('required', []) - for field in required_fields: - if field not in data: - result.add_error( - ParseErrorType.MISSING_CONTENT, - f"Missing required field: {field}", - field=field, - suggestions=[f"Ensure tool returns '{field}' field"] - ) - - # Validate each field - properties = schema.get('properties', {}) - for field_name, field_schema in properties.items(): - if field_name in data: - validated_value = self._validate_field( - field_name, - data[field_name], - field_schema, - result - ) - if validated_value is not None: - validated[field_name] = validated_value - elif field_name in required_fields: - # Already handled above - pass - else: - # Optional field with default - default_value = field_schema.get('default') - if default_value is not None: - validated[field_name] = default_value - - # Check for unexpected fields - for field_name in data: - if field_name not in properties: - result.add_warning(f"Unexpected field: {field_name}") - validated[field_name] = data[field_name] # Include anyway - - return validated - - def _validate_field(self, field_name: str, value: Any, schema: Dict[str, Any], result: ParseResult) -> Any: - """Validate a single field.""" - expected_type = schema.get('type') - - # Type validation - if expected_type: - if not self._check_type(value, expected_type): - # Try type conversion - converted = self._convert_type(value, expected_type) - if converted is not None: - result.add_warning(f"Converted {field_name} from {type(value).__name__} to {expected_type}") - value = converted - else: - result.add_error( - ParseErrorType.TYPE_MISMATCH, - f"Field '{field_name}' expected {expected_type}, got {type(value).__name__}", - field=field_name, - raw_value=value, - suggestions=[f"Ensure tool returns {expected_type} for {field_name}"] - ) - return None - - # Range validation for numbers - if expected_type in ['number', 'integer'] and isinstance(value, (int, float)): - minimum = schema.get('minimum') - maximum = schema.get('maximum') - - if minimum is not None and value < minimum: - result.add_error( - ParseErrorType.VALIDATION_FAILED, - f"Field '{field_name}' value {value} below minimum {minimum}", - field=field_name, - raw_value=value - ) - return None - - if maximum is not None and value > maximum: - result.add_error( - ParseErrorType.VALIDATION_FAILED, - f"Field '{field_name}' value {value} above maximum {maximum}", - field=field_name, - raw_value=value - ) - return None - - # String length validation - if expected_type == 'string' and isinstance(value, str): - min_length = schema.get('minLength') - max_length = schema.get('maxLength') - - if min_length is not None and len(value) < min_length: - result.add_error( - ParseErrorType.VALIDATION_FAILED, - f"Field '{field_name}' length {len(value)} below minimum {min_length}", - field=field_name, - raw_value=value - ) - return None - - if max_length is not None and len(value) > max_length: - result.add_error( - ParseErrorType.VALIDATION_FAILED, - f"Field '{field_name}' length {len(value)} above maximum {max_length}", - field=field_name, - raw_value=value - ) - return None - - # Pattern validation - pattern = schema.get('pattern') - if pattern and isinstance(value, str): - import re - if not re.match(pattern, value): - result.add_error( - ParseErrorType.VALIDATION_FAILED, - f"Field '{field_name}' does not match pattern: {pattern}", - field=field_name, - raw_value=value, - suggestions=[f"Ensure {field_name} matches format: {pattern}"] - ) - return None - - # Enum validation - enum_values = schema.get('enum') - if enum_values and value not in enum_values: - result.add_error( - ParseErrorType.VALIDATION_FAILED, - f"Field '{field_name}' value '{value}' not in allowed values: {enum_values}", - field=field_name, - raw_value=value, - suggestions=[f"Use one of: {', '.join(map(str, enum_values))}"] - ) - return None - - # Custom validation - if field_name in self.validation_rules: - try: - custom_result = self.validation_rules[field_name](value) - if custom_result is not True: - result.add_error( - ParseErrorType.VALIDATION_FAILED, - f"Custom validation failed for '{field_name}': {custom_result}", - field=field_name, - raw_value=value - ) - return None - except Exception as e: - result.add_error( - ParseErrorType.VALIDATION_FAILED, - f"Custom validation error for '{field_name}': {e}", - field=field_name, - raw_value=value - ) - return None - - return value - - def _check_type(self, value: Any, expected_type: str) -> bool: - """Check if value matches expected type.""" - type_map = { - 'string': str, - 'number': (int, float), - 'integer': int, - 'boolean': bool, - 'array': list, - 'object': dict - } - - expected_python_type = type_map.get(expected_type) - if expected_python_type: - return isinstance(value, expected_python_type) - - return True # Unknown type, assume valid - - def _convert_type(self, value: Any, expected_type: str) -> Any: - """Attempt to convert value to expected type.""" - try: - if expected_type == 'string': - return str(value) - elif expected_type == 'number': - return float(value) - elif expected_type == 'integer': - return int(float(value)) # Handle string numbers - elif expected_type == 'boolean': - if isinstance(value, str): - return value.lower() in ('true', '1', 'yes', 'on') - return bool(value) - elif expected_type == 'array': - if isinstance(value, str): - # Try to parse as JSON array - return json.loads(value) - return list(value) - elif expected_type == 'object': - if isinstance(value, str): - return json.loads(value) - return dict(value) - except (ValueError, TypeError, json.JSONDecodeError): - pass - - return None - -# Usage example -def error_handling_example(): - """Example of robust error handling.""" - parser = RobustParser() - - # Register custom validator - def validate_email(value): - import re - pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$' - if re.match(pattern, value): - return True - return "Invalid email format" - - parser.register_validator('email', validate_email) - - # Define expected schema - schema = { - 'type': 'object', - 'required': ['name', 'age'], - 'properties': { - 'name': { - 'type': 'string', - 'minLength': 1, - 'maxLength': 50 - }, - 'age': { - 'type': 'integer', - 'minimum': 0, - 'maximum': 150 - }, - 'email': { - 'type': 'string' - }, - 'status': { - 'type': 'string', - 'enum': ['active', 'inactive', 'pending'] - } - } - } - - # Test with valid data - valid_data = { - 'name': 'John Doe', - 'age': 30, - 'email': 'john@example.com', - 'status': 'active' - } - - result = parser.parse_with_validation(valid_data, schema) - print(f"Valid data - Success: {result.success}") - if result.success: - print(f"Parsed data: {result.data}") - - # Test with invalid data - invalid_data = { - 'name': '', # Too short - 'age': '200', # Over maximum, but convertible - 'email': 'invalid-email', # Invalid format - 'status': 'unknown', # Not in enum - 'extra': 'field' # Unexpected field - } - - result = parser.parse_with_validation(invalid_data, schema) - print(f"\\nInvalid data - Success: {result.success}") - - for error in result.errors: - print(f"ERROR ({error.error_type.value}): {error.message}") - if error.field: - print(f" Field: {error.field}") - if error.suggestions: - print(f" Suggestions: {', '.join(error.suggestions)}") - - for warning in result.warnings: - print(f"WARNING: {warning}") - -if __name__ == "__main__": - error_handling_example() -``` - -## Best practices - -### Performance optimization - -- **Lazy parsing** - Parse content only when accessed -- **Caching** - Cache parsed results for repeated access -- **Streaming** - Process large results in chunks -- **Type hints** - Use type annotations for better IDE support -- **Validation limits** - Set reasonable limits for validation complexity - -### Error resilience - -- **Graceful degradation** - Fall back to text content when parsing fails -- **Detailed errors** - Provide specific error messages with suggestions -- **Partial parsing** - Extract what's possible even when some parts fail -- **Logging** - Log parsing issues for debugging -- **Recovery strategies** - Implement fallback parsing methods - -### Data integrity - -- **Schema validation** - Validate against expected schemas -- **Type checking** - Ensure data types match expectations -- **Range validation** - Check numeric ranges and string lengths -- **Format validation** - Validate specific formats like emails and URLs -- **Consistency checks** - Verify data consistency across fields - -## Next steps - -- **[OAuth for clients](oauth-clients.md)** - Secure authentication in clients -- **[Display utilities](display-utilities.md)** - Format parsed data for display -- **[Writing clients](writing-clients.md)** - Complete client development -- **[Low-level server](low-level-server.md)** - Understanding server responses \ No newline at end of file diff --git a/docs/progress-logging.md b/docs/progress-logging.md deleted file mode 100644 index 69ecb8bfa..000000000 --- a/docs/progress-logging.md +++ /dev/null @@ -1,226 +0,0 @@ -# Progress & logging - -Learn how to implement comprehensive logging and progress reporting in your MCP servers to provide users with real-time feedback and debugging information. - -## Logging basics - -### Log levels and usage - -```python -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - -mcp = FastMCP("Logging Example") - -@mcp.tool() -async def demonstrate_logging(operation: str, ctx: Context[ServerSession, None]) -> str: - """Demonstrate different logging levels.""" - - # Debug: Detailed information for debugging - await ctx.debug(f"Starting operation: {operation}") - await ctx.debug("Initializing operation parameters") - - # Info: General information about operation progress - await ctx.info(f"Processing operation: {operation}") - - # Warning: Something unexpected but not critical - if operation == "risky_operation": - await ctx.warning("This operation has known limitations") - - # Error: Something went wrong - if operation == "failing_operation": - await ctx.error("Operation failed due to invalid input") - raise ValueError("Operation not supported") - - await ctx.info(f"Operation '{operation}' completed successfully") - return f"Completed: {operation}" - -@mcp.tool() -async def structured_logging( - data: dict, - ctx: Context[ServerSession, None] -) -> dict: - """Example of structured logging with context.""" - - operation_id = f"op_{hash(str(data)) % 10000:04d}" - - await ctx.info(f"[{operation_id}] Starting data processing") - await ctx.debug(f"[{operation_id}] Input data: {len(data)} fields") - - try: - # Simulate processing - processed_count = 0 - for key, value in data.items(): - await ctx.debug(f"[{operation_id}] Processing field: {key}") - processed_count += 1 - - await ctx.info(f"[{operation_id}] Processed {processed_count} fields successfully") - - return { - "operation_id": operation_id, - "status": "success", - "processed_fields": processed_count - } - - except Exception as e: - await ctx.error(f"[{operation_id}] Processing failed: {e}") - raise -``` - -## Progress reporting - -### Basic progress updates - -```python -import asyncio - -@mcp.tool() -async def long_running_task( - total_steps: int, - ctx: Context[ServerSession, None] -) -> str: - """Demonstrate basic progress reporting.""" - - await ctx.info(f"Starting task with {total_steps} steps") - - for i in range(total_steps): - # Simulate work - await asyncio.sleep(0.1) - - # Calculate progress - progress = (i + 1) / total_steps - - # Report progress - await ctx.report_progress( - progress=progress, - total=1.0, - message=f"Completed step {i + 1} of {total_steps}" - ) - - await ctx.debug(f"Step {i + 1} completed") - - await ctx.info("All steps completed") - return f"Successfully completed {total_steps} steps" - -@mcp.tool() -async def detailed_progress_task( - phases: list[str], - ctx: Context[ServerSession, None] -) -> dict: - """Multi-phase task with detailed progress reporting.""" - - total_phases = len(phases) - await ctx.info(f"Starting multi-phase task: {total_phases} phases") - - results = {} - - for phase_idx, phase_name in enumerate(phases): - await ctx.info(f"Starting phase {phase_idx + 1}/{total_phases}: {phase_name}") - - # Simulate phase work with sub-progress - phase_steps = 5 # Each phase has 5 steps - - for step in range(phase_steps): - # Simulate step work - await asyncio.sleep(0.05) - - # Calculate overall progress - completed_phases = phase_idx - phase_progress = (step + 1) / phase_steps - overall_progress = (completed_phases + phase_progress) / total_phases - - # Report progress with detailed message - await ctx.report_progress( - progress=overall_progress, - total=1.0, - message=f"Phase {phase_idx + 1}/{total_phases} ({phase_name}): Step {step + 1}/{phase_steps}" - ) - - await ctx.debug(f"Phase '{phase_name}' step {step + 1} completed") - - results[phase_name] = f"Completed in {phase_steps} steps" - await ctx.info(f"Phase '{phase_name}' completed") - - await ctx.info("All phases completed successfully") - - return { - "status": "completed", - "phases": results, - "total_phases": total_phases - } -``` - -### Advanced progress patterns - -```python -from typing import Callable, Awaitable - -async def progress_wrapper( - tasks: list[Callable[[], Awaitable[any]]], - task_names: list[str], - ctx: Context[ServerSession, None] -) -> list[any]: - """Execute multiple tasks with combined progress reporting.""" - - if len(tasks) != len(task_names): - raise ValueError("Tasks and names lists must have same length") - - await ctx.info(f"Executing {len(tasks)} tasks with progress tracking") - - results = [] - total_tasks = len(tasks) - - for i, (task, name) in enumerate(zip(tasks, task_names)): - await ctx.info(f"Starting task {i + 1}/{total_tasks}: {name}") - - try: - # Execute task - result = await task() - results.append(result) - - # Report completion - progress = (i + 1) / total_tasks - await ctx.report_progress( - progress=progress, - total=1.0, - message=f"Completed {i + 1}/{total_tasks}: {name}" - ) - - await ctx.info(f"Task '{name}' completed successfully") - - except Exception as e: - await ctx.error(f"Task '{name}' failed: {e}") - results.append(None) - - # Continue with next task - progress = (i + 1) / total_tasks - await ctx.report_progress( - progress=progress, - total=1.0, - message=f"Failed {i + 1}/{total_tasks}: {name} (continuing...)" - ) - - successful_tasks = sum(1 for r in results if r is not None) - await ctx.info(f"Completed {successful_tasks}/{total_tasks} tasks successfully") - - return results - -@mcp.tool() -async def batch_processing( - items: list[str], - ctx: Context[ServerSession, None] -) -> dict: - """Process items in batches with progress reporting.""" - - batch_size = 3 - total_items = len(items) - batches = [items[i:i + batch_size] for i in range(0, total_items, batch_size)] - total_batches = len(batches) - - await ctx.info(f"Processing {total_items} items in {total_batches} batches") - - processed_items = [] - failed_items = [] - - for batch_idx, batch in enumerate(batches): - await ctx.info(f"Processing batch {batch_idx + 1}/{total_batches} ({len(batch)} items)\")\n \n for item_idx, item in enumerate(batch):\n try:\n # Simulate item processing\n await asyncio.sleep(0.1)\n processed_items.append(f\"processed_{item}\")\n \n # Calculate detailed progress\n items_completed = len(processed_items) + len(failed_items)\n progress = items_completed / total_items\n \n await ctx.report_progress(\n progress=progress,\n total=1.0,\n message=f\"Batch {batch_idx + 1}/{total_batches}, Item {item_idx + 1}/{len(batch)}: {item}\"\n )\n \n await ctx.debug(f\"Successfully processed item: {item}\")\n \n except Exception as e:\n await ctx.warning(f\"Failed to process item '{item}': {e}\")\n failed_items.append(item)\n \n await ctx.info(f\"Batch {batch_idx + 1} completed\")\n \n await ctx.info(f\"Processing complete: {len(processed_items)} successful, {len(failed_items)} failed\")\n \n return {\n \"total_items\": total_items,\n \"processed_count\": len(processed_items),\n \"failed_count\": len(failed_items),\n \"processed_items\": processed_items,\n \"failed_items\": failed_items\n }\n```\n\n## Custom logging patterns\n\n### Contextual logging\n\n```python\nfrom dataclasses import dataclass\nfrom datetime import datetime\n\n@dataclass\nclass LogContext:\n \"\"\"Context information for enhanced logging.\"\"\"\n user_id: str | None = None\n session_id: str | None = None\n operation_id: str | None = None\n timestamp: datetime | None = None\n\nclass EnhancedLogger:\n \"\"\"Enhanced logger with context management.\"\"\"\n \n def __init__(self, ctx: Context):\n self.ctx = ctx\n self.log_context = LogContext()\n \n def set_context(self, **kwargs):\n \"\"\"Update logging context.\"\"\"\n for key, value in kwargs.items():\n if hasattr(self.log_context, key):\n setattr(self.log_context, key, value)\n \n async def log_with_context(self, level: str, message: str):\n \"\"\"Log message with context information.\"\"\"\n context_parts = []\n \n if self.log_context.user_id:\n context_parts.append(f\"user:{self.log_context.user_id}\")\n if self.log_context.session_id:\n context_parts.append(f\"session:{self.log_context.session_id}\")\n if self.log_context.operation_id:\n context_parts.append(f\"op:{self.log_context.operation_id}\")\n \n context_str = \"[\" + \",\".join(context_parts) + \"]\" if context_parts else \"\"\n full_message = f\"{context_str} {message}\" if context_str else message\n \n # Use appropriate log level\n if level == \"debug\":\n await self.ctx.debug(full_message)\n elif level == \"info\":\n await self.ctx.info(full_message)\n elif level == \"warning\":\n await self.ctx.warning(full_message)\n elif level == \"error\":\n await self.ctx.error(full_message)\n else:\n await self.ctx.log(level, full_message)\n\n@mcp.tool()\nasync def contextual_operation(\n user_id: str,\n data: dict,\n ctx: Context[ServerSession, None]\n) -> dict:\n \"\"\"Operation with contextual logging.\"\"\"\n \n # Set up enhanced logger\n logger = EnhancedLogger(ctx)\n logger.set_context(\n user_id=user_id,\n session_id=ctx.request_id[:8],\n operation_id=f\"ctx_op_{hash(user_id) % 1000:03d}\",\n timestamp=datetime.now()\n )\n \n await logger.log_with_context(\"info\", \"Starting contextual operation\")\n \n try:\n # Process data with contextual logging\n await logger.log_with_context(\"debug\", f\"Processing {len(data)} data fields\")\n \n processed_data = {}\n for key, value in data.items():\n await logger.log_with_context(\"debug\", f\"Processing field: {key}\")\n processed_data[key] = f\"processed_{value}\"\n \n await logger.log_with_context(\"info\", \"Operation completed successfully\")\n \n return {\n \"status\": \"success\",\n \"user_id\": user_id,\n \"processed_fields\": len(processed_data),\n \"operation_id\": logger.log_context.operation_id\n }\n \n except Exception as e:\n await logger.log_with_context(\"error\", f\"Operation failed: {e}\")\n raise\n```\n\n### Performance logging\n\n```python\nimport time\nfrom functools import wraps\n\ndef performance_logged(func):\n \"\"\"Decorator to add performance logging to tools.\"\"\"\n \n @wraps(func)\n async def wrapper(*args, **kwargs):\n # Find context in arguments\n ctx = None\n for arg in args:\n if isinstance(arg, Context):\n ctx = arg\n break\n \n if not ctx:\n return await func(*args, **kwargs)\n \n # Start timing\n start_time = time.time()\n function_name = func.__name__\n \n await ctx.info(f\"[PERF] Starting {function_name}\")\n await ctx.debug(f\"[PERF] {function_name} called with {len(args)} args\")\n \n try:\n result = await func(*args, **kwargs)\n \n # Log success with timing\n duration = time.time() - start_time\n await ctx.info(f\"[PERF] {function_name} completed in {duration:.3f}s\")\n \n if duration > 5.0: # Warn about slow operations\n await ctx.warning(f\"[PERF] Slow operation detected: {function_name} took {duration:.3f}s\")\n \n return result\n \n except Exception as e:\n # Log failure with timing\n duration = time.time() - start_time\n await ctx.error(f\"[PERF] {function_name} failed after {duration:.3f}s: {e}\")\n raise\n \n return wrapper\n\n@mcp.tool()\n@performance_logged\nasync def performance_monitored_task(\n complexity: str,\n ctx: Context[ServerSession, None]\n) -> dict:\n \"\"\"Task with automatic performance monitoring.\"\"\"\n \n # Simulate different complexity levels\n if complexity == \"light\":\n await asyncio.sleep(0.1)\n operations = 10\n elif complexity == \"medium\":\n await asyncio.sleep(1.0)\n operations = 100\n elif complexity == \"heavy\":\n await asyncio.sleep(3.0)\n operations = 1000\n else:\n await asyncio.sleep(0.05)\n operations = 5\n \n return {\n \"complexity\": complexity,\n \"operations_performed\": operations,\n \"status\": \"completed\"\n }\n```\n\n## Error logging and debugging\n\n### Comprehensive error handling\n\n```python\nimport traceback\nfrom typing import Any\n\n@mcp.tool()\nasync def robust_operation(\n operation_type: str,\n parameters: dict[str, Any],\n ctx: Context[ServerSession, None]\n) -> dict:\n \"\"\"Operation with comprehensive error logging.\"\"\"\n \n operation_id = f\"rob_{hash(operation_type) % 1000:03d}\"\n \n await ctx.info(f\"[{operation_id}] Starting robust operation: {operation_type}\")\n await ctx.debug(f\"[{operation_id}] Parameters: {parameters}\")\n \n try:\n # Validate parameters\n if not isinstance(parameters, dict):\n raise ValueError(\"Parameters must be a dictionary\")\n \n await ctx.debug(f\"[{operation_id}] Parameter validation passed\")\n \n # Simulate operation based on type\n if operation_type == \"process_data\":\n if \"data\" not in parameters:\n raise KeyError(\"Missing required parameter: data\")\n \n data = parameters[\"data\"]\n await ctx.info(f\"[{operation_id}] Processing {len(data) if hasattr(data, '__len__') else 'unknown size'} data\")\n \n # Simulate processing with potential failures\n if data == \"invalid_data\":\n raise ValueError(\"Invalid data format detected\")\n \n result = f\"Processed: {data}\"\n \n elif operation_type == \"network_call\":\n url = parameters.get(\"url\")\n if not url:\n raise ValueError(\"URL parameter required for network_call\")\n \n await ctx.info(f\"[{operation_id}] Making network call to: {url}\")\n \n # Simulate network issues\n if \"error\" in url:\n raise ConnectionError(f\"Failed to connect to {url}\")\n \n result = f\"Response from {url}\"\n \n else:\n raise NotImplementedError(f\"Operation type '{operation_type}' not supported\")\n \n await ctx.info(f\"[{operation_id}] Operation completed successfully\")\n \n return {\n \"operation_id\": operation_id,\n \"status\": \"success\",\n \"result\": result,\n \"operation_type\": operation_type\n }\n \n except KeyError as e:\n await ctx.error(f\"[{operation_id}] Missing parameter: {e}\")\n await ctx.debug(f\"[{operation_id}] Available parameters: {list(parameters.keys())}\")\n \n return {\n \"operation_id\": operation_id,\n \"status\": \"error\",\n \"error_type\": \"missing_parameter\",\n \"error_message\": str(e)\n }\n \n except ValueError as e:\n await ctx.error(f\"[{operation_id}] Invalid parameter value: {e}\")\n await ctx.debug(f\"[{operation_id}] Parameter validation failed\")\n \n return {\n \"operation_id\": operation_id,\n \"status\": \"error\",\n \"error_type\": \"invalid_parameter\",\n \"error_message\": str(e)\n }\n \n except Exception as e:\n # Log full exception details\n await ctx.error(f\"[{operation_id}] Unexpected error: {e}\")\n await ctx.debug(f\"[{operation_id}] Full traceback: {traceback.format_exc()}\")\n \n return {\n \"operation_id\": operation_id,\n \"status\": \"error\",\n \"error_type\": \"unexpected_error\",\n \"error_message\": str(e)\n }\n```\n\n## Notifications and resource updates\n\n### Resource change notifications\n\n```python\n@mcp.resource(\"status://{service}\")\ndef get_service_status(service: str) -> str:\n \"\"\"Get status of a service.\"\"\"\n # Simulate service status\n statuses = {\n \"database\": \"operational\",\n \"api\": \"degraded\",\n \"cache\": \"maintenance\"\n }\n return f\"Service '{service}' status: {statuses.get(service, 'unknown')}\"\n\n@mcp.tool()\nasync def update_service_status(\n service: str,\n new_status: str,\n ctx: Context[ServerSession, None]\n) -> dict:\n \"\"\"Update service status and notify clients.\"\"\"\n \n await ctx.info(f\"Updating {service} status to: {new_status}\")\n \n # Update status (in a real app, this would update a database)\n # statuses[service] = new_status\n \n # Notify clients about the resource change\n resource_uri = f\"status://{service}\"\n await ctx.session.send_resource_updated(resource_uri)\n \n await ctx.info(f\"Status update notification sent for {service}\")\n \n return {\n \"service\": service,\n \"new_status\": new_status,\n \"notification_sent\": True\n }\n\n@mcp.tool()\nasync def bulk_status_update(\n updates: dict[str, str],\n ctx: Context[ServerSession, None]\n) -> dict:\n \"\"\"Update multiple service statuses.\"\"\"\n \n await ctx.info(f\"Starting bulk update for {len(updates)} services\")\n \n updated_services = []\n \n for service, status in updates.items():\n try:\n await ctx.debug(f\"Updating {service} to {status}\")\n \n # Update status\n # statuses[service] = status\n \n # Send individual resource update\n await ctx.session.send_resource_updated(f\"status://{service}\")\n \n updated_services.append(service)\n \n except Exception as e:\n await ctx.warning(f\"Failed to update {service}: {e}\")\n \n # Notify that the overall resource list may have changed\n await ctx.session.send_resource_list_changed()\n \n await ctx.info(f\"Bulk update completed: {len(updated_services)} services updated\")\n \n return {\n \"total_updates\": len(updates),\n \"successful_updates\": len(updated_services),\n \"updated_services\": updated_services\n }\n```\n\n## Testing logging and progress\n\n### Unit testing with log verification\n\n```python\nimport pytest\nfrom unittest.mock import AsyncMock, Mock\n\n@pytest.mark.asyncio\nasync def test_logging_functionality():\n \"\"\"Test that logging works correctly.\"\"\"\n \n # Mock context with logging methods\n mock_ctx = Mock()\n mock_ctx.info = AsyncMock()\n mock_ctx.debug = AsyncMock()\n mock_ctx.warning = AsyncMock()\n mock_ctx.error = AsyncMock()\n \n # Test the logging function\n result = await demonstrate_logging(\"test_operation\", mock_ctx)\n \n # Verify logging calls were made\n mock_ctx.debug.assert_called()\n mock_ctx.info.assert_called()\n assert \"test_operation\" in str(result)\n\n@pytest.mark.asyncio\nasync def test_progress_reporting():\n \"\"\"Test progress reporting functionality.\"\"\"\n \n mock_ctx = Mock()\n mock_ctx.info = AsyncMock()\n mock_ctx.debug = AsyncMock()\n mock_ctx.report_progress = AsyncMock()\n \n # Test progress function\n result = await long_running_task(3, mock_ctx)\n \n # Verify progress was reported\n assert mock_ctx.report_progress.call_count == 3\n \n # Check progress values\n calls = mock_ctx.report_progress.call_args_list\n assert calls[0][1]['progress'] == 1/3 # First progress report\n assert calls[1][1]['progress'] == 2/3 # Second progress report\n assert calls[2][1]['progress'] == 1.0 # Final progress report\n```\n\n## Best practices\n\n### Logging guidelines\n\n- **Appropriate levels** - Use debug for detailed info, info for general progress, warning for issues, error for failures\n- **Structured messages** - Include operation IDs and context information\n- **Performance awareness** - Log timing information for slow operations\n- **Error details** - Include full error context without exposing sensitive data\n\n### Progress reporting best practices\n\n- **Frequent updates** - Update progress regularly but not excessively\n- **Meaningful messages** - Provide clear descriptions of current activity\n- **Accurate percentages** - Ensure progress values are accurate and monotonic\n- **Error handling** - Continue reporting progress even when some operations fail\n\n### Performance considerations\n\n- **Async logging** - Use async logging methods to avoid blocking\n- **Log levels** - Filter logs appropriately in production\n- **Batch operations** - Group related log messages when possible\n- **Resource cleanup** - Clean up progress tracking resources\n\n## Next steps\n\n- **[Context patterns](context.md)** - Advanced context usage for logging\n- **[Authentication](authentication.md)** - Security logging and audit trails\n- **[Error handling](tools.md#error-handling-and-validation)** - Comprehensive error handling patterns\n- **[Performance optimization](servers.md#performance-considerations)** - Server performance monitoring \ No newline at end of file diff --git a/docs/prompts.md b/docs/prompts.md deleted file mode 100644 index 44548c6a0..000000000 --- a/docs/prompts.md +++ /dev/null @@ -1,562 +0,0 @@ -# Prompts - -Prompts are reusable templates that help structure LLM interactions. They provide a standardized way to request specific types of responses from LLMs. - -## What are prompts? - -Prompts in MCP are: - -- **Templates** - Reusable patterns for LLM interactions -- **User-controlled** - Invoked by user choice, not automatically by LLMs -- **Parameterized** - Accept arguments to customize the prompt -- **Structured** - Can include multiple messages and roles - -## Basic prompt creation - -### Simple prompts - -```python -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP("Prompt Examples") - -@mcp.prompt() -def write_email(recipient: str, subject: str, tone: str = "professional") -> str: - """Generate an email writing prompt.""" - return f"""Please write an email to {recipient} with the subject "{subject}". - -Use a {tone} tone and include: -- A clear purpose -- Appropriate greeting and closing -- Professional formatting -""" - -@mcp.prompt() -def code_review(language: str, code_snippet: str) -> str: - """Generate a code review prompt.""" - return f"""Please review this {language} code: - -```{language} -{code_snippet} -``` - -Focus on: -- Code quality and best practices -- Potential bugs or issues -- Performance considerations -- Readability and maintainability -""" -``` - -### Prompts with titles - -```python -@mcp.prompt(title="Creative Writing Assistant") -def creative_writing(genre: str, theme: str, length: str = "short") -> str: - """Generate a creative writing prompt.""" - return f"""Write a {length} {genre} story incorporating the theme of "{theme}". - -Guidelines: -- Create compelling characters -- Build tension and conflict -- Include vivid descriptions -- Provide a satisfying resolution -""" - -@mcp.prompt(title="Technical Documentation Helper") -def tech_docs(feature: str, audience: str = "developers") -> str: - """Generate a technical documentation prompt.""" - return f"""Create comprehensive documentation for the "{feature}" feature. - -Target audience: {audience} - -Include: -- Clear overview and purpose -- Step-by-step usage instructions -- Code examples where applicable -- Common troubleshooting scenarios -- Best practices and tips -""" -``` - -## Advanced prompt patterns - -### Multi-message prompts - -```python -from mcp.server.fastmcp.prompts import base - -@mcp.prompt(title="Interview Preparation") -def interview_prep(role: str, company: str, experience_level: str) -> list[base.Message]: - """Generate an interview preparation conversation.""" - return [ - base.UserMessage( - f"I'm preparing for a {role} interview at {company}. " - f"I have {experience_level} level experience." - ), - base.AssistantMessage( - "I'll help you prepare! Let me start with some key questions " - "you should be ready to answer:" - ), - base.UserMessage( - "What are the most important technical concepts I should review?" - ) - ] - -@mcp.prompt(title="Debugging Session") -def debug_session( - error_message: str, - language: str, - context: str = "web application" -) -> list[base.Message]: - """Create a debugging conversation prompt.""" - return [ - base.UserMessage( - f"I'm getting this error in my {language} {context}:" - ), - base.UserMessage(error_message), - base.AssistantMessage( - "Let me help you debug this. First, let's understand the context better." - ), - base.UserMessage( - "What additional information do you need to help solve this?" - ) - ] -``` - -### Context-aware prompts - -```python -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - -mcp = FastMCP("Context-Aware Prompts") - -@mcp.prompt() -async def personalized_learning( - topic: str, - difficulty: str, - ctx: Context[ServerSession, None] -) -> str: - """Generate a learning prompt customized to the user.""" - # In a real application, you might fetch user preferences - user_id = getattr(ctx.session, 'user_id', 'anonymous') - - await ctx.info(f"Creating learning prompt for user {user_id}") - - return f"""Create a {difficulty} level learning plan for: {topic} - -Customize the approach based on: -- Learning style: visual and hands-on preferred -- Time available: 30-45 minutes per session -- Goal: practical application of concepts - -Structure: -1. Key concepts overview -2. Step-by-step learning path -3. Practical exercises -4. Resources for deeper learning -""" - -@mcp.prompt() -async def project_analysis( - project_type: str, - requirements: str, - ctx: Context[ServerSession, None] -) -> str: - """Generate a project analysis prompt with server context.""" - server_name = ctx.fastmcp.name - - return f"""As an expert analyst working with {server_name}, please analyze this {project_type} project: - -Requirements: -{requirements} - -Provide: -1. Technical feasibility assessment -2. Resource requirements estimation -3. Timeline and milestone suggestions -4. Risk analysis and mitigation strategies -5. Technology stack recommendations -""" -``` - -### Data-driven prompts - -```python -from datetime import datetime -import json - -@mcp.prompt() -def daily_standup(team_member: str, yesterday_tasks: list[str]) -> str: - """Generate a daily standup prompt.""" - today = datetime.now().strftime("%Y-%m-%d") - - tasks_summary = "\\n".join(f"- {task}" for task in yesterday_tasks) - - return f"""Daily Standup for {team_member} - {today} - -Yesterday's completed tasks: -{tasks_summary} - -Please provide your standup update covering: - -1. **What did you accomplish yesterday?** - (Reference the tasks above and any additional work) - -2. **What are you planning to work on today?** - (List your priorities and focus areas) - -3. **Are there any blockers or impediments?** - (Identify anything that might slow down progress) - -4. **Do you need help from the team?** - (Mention any collaboration or support needed) -""" - -@mcp.prompt() -def code_optimization( - language: str, - performance_metrics: dict[str, float], - code_section: str -) -> str: - """Generate a code optimization prompt with performance data.""" - metrics_text = "\\n".join( - f"- {metric}: {value}" for metric, value in performance_metrics.items() - ) - - return f"""Optimize this {language} code based on performance analysis: - -Current Performance Metrics: -{metrics_text} - -Code to optimize: -```{language} -{code_section} -``` - -Focus on: -1. Identifying performance bottlenecks -2. Suggesting specific optimizations -3. Explaining the reasoning behind each suggestion -4. Estimating performance impact -5. Maintaining code readability and maintainability - -Provide optimized code with detailed explanations. -""" -``` - -## Prompt composition patterns - -### Modular prompts - -```python -def get_writing_guidelines(tone: str) -> str: - """Get writing guidelines based on tone.""" - guidelines = { - "professional": "Use formal language, clear structure, and avoid colloquialisms", - "casual": "Use conversational language, contractions, and a friendly approach", - "academic": "Use precise terminology, citations, and formal academic structure", - "creative": "Use vivid imagery, varied sentence structure, and engaging language" - } - return guidelines.get(tone, guidelines["professional"]) - -def get_length_instructions(length: str) -> str: - """Get length-specific instructions.""" - instructions = { - "brief": "Keep it concise - aim for 1-2 paragraphs maximum", - "medium": "Provide moderate detail - aim for 3-5 paragraphs", - "detailed": "Be comprehensive - provide thorough analysis and examples", - "comprehensive": "Include all relevant information - create a complete reference" - } - return instructions.get(length, instructions["medium"]) - -@mcp.prompt(title="Modular Content Generator") -def generate_content( - topic: str, - content_type: str, - tone: str = "professional", - length: str = "medium" -) -> str: - """Generate content using modular prompt components.""" - writing_guidelines = get_writing_guidelines(tone) - length_instructions = get_length_instructions(length) - - return f"""Create {content_type} content about: {topic} - -Writing Guidelines: -{writing_guidelines} - -Length Requirements: -{length_instructions} - -Structure your response with: -1. Engaging opening -2. Well-organized main content -3. Clear conclusion or call-to-action - -Additional requirements: -- Use appropriate headings and formatting -- Include relevant examples where helpful -- Ensure accuracy and credibility -""" -``` - -### Conditional prompts - -```python -@mcp.prompt() -def learning_assessment( - subject: str, - current_level: str, - learning_goals: list[str], - time_available: str -) -> str: - """Generate learning prompts based on user level and goals.""" - - # Customize based on current level - if current_level.lower() == "beginner": - approach = """ - Start with fundamental concepts and basic terminology. - Use simple examples and step-by-step explanations. - Focus on building a solid foundation before advanced topics. - """ - elif current_level.lower() == "intermediate": - approach = """ - Build on existing knowledge with more complex scenarios. - Include real-world applications and case studies. - Challenge assumptions and introduce advanced concepts. - """ - else: # advanced - approach = """ - Dive deep into expert-level concepts and edge cases. - Explore cutting-edge developments and research. - Focus on optimization, best practices, and innovation. - """ - - # Customize based on time available - if "week" in time_available.lower(): - timeline = "Create a week-long intensive learning plan" - elif "month" in time_available.lower(): - timeline = "Design a month-long comprehensive curriculum" - else: - timeline = "Structure for flexible, self-paced learning" - - goals_text = "\\n".join(f"- {goal}" for goal in learning_goals) - - return f"""Create a personalized {subject} learning plan: - -Current Level: {current_level} -Learning Goals: -{goals_text} - -Time Frame: {time_available} - -Learning Approach: -{approach} - -Planning Instructions: -{timeline} - -Include: -1. Learning path and milestones -2. Recommended resources and materials -3. Practice exercises and projects -4. Progress assessment methods -5. Tips for overcoming common challenges -""" -``` - -## Integration with other MCP features - -### Prompts that reference resources - -```python -@mcp.resource("documentation://{section}") -def get_documentation(section: str) -> str: - """Get documentation for a specific section.""" - docs = { - "api": "API Documentation: Use GET /users for user list...", - "setup": "Setup Guide: Install dependencies with npm install...", - "troubleshooting": "Troubleshooting: Common issues and solutions..." - } - return docs.get(section, "Documentation section not found") - -@mcp.prompt() -def help_with_documentation(section: str, specific_question: str) -> str: - """Generate a prompt that references documentation resources.""" - return f"""I need help with the {section} documentation. - -Specific question: {specific_question} - -Please: -1. Read the documentation resource: documentation://{section} -2. Answer my specific question based on the documentation -3. Provide additional context or examples if helpful -4. Suggest related documentation sections if relevant - -If the documentation doesn't fully answer my question, please: -- Explain what information is available -- Suggest alternative approaches -- Recommend additional resources -""" -``` - -### Prompts for tool workflows - -```python -@mcp.prompt() -def data_analysis_workflow( - data_source: str, - analysis_type: str, - output_format: str = "report" -) -> str: - """Generate a prompt for data analysis using available tools.""" - return f"""Perform a comprehensive data analysis workflow: - -Data Source: {data_source} -Analysis Type: {analysis_type} -Output Format: {output_format} - -Workflow steps: -1. Use the `load_data` tool to import data from {data_source} -2. Use the `analyze_data` tool to perform {analysis_type} analysis -3. Use the `visualize_results` tool to create appropriate charts -4. Use the `generate_report` tool to create a {output_format} - -For each step: -- Explain the rationale for your approach -- Describe any insights or patterns discovered -- Note any data quality issues or limitations -- Suggest next steps or follow-up analyses - -Provide a complete analysis with actionable insights. -""" -``` - -## Testing prompts - -### Unit testing - -```python -import pytest -from mcp.server.fastmcp import FastMCP - -def test_simple_prompt(): - mcp = FastMCP("Test") - - @mcp.prompt() - def test_prompt(name: str) -> str: - return f"Hello, {name}!" - - result = test_prompt("World") - assert "Hello, World!" in result - -def test_parameterized_prompt(): - mcp = FastMCP("Test") - - @mcp.prompt() - def email_prompt(recipient: str, tone: str = "professional") -> str: - return f"Write a {tone} email to {recipient}" - - result = email_prompt("Alice", "friendly") - assert "friendly" in result - assert "Alice" in result - -def test_multi_message_prompt(): - mcp = FastMCP("Test") - - @mcp.prompt() - def conversation() -> list: - return [ - {"role": "user", "text": "Hello"}, - {"role": "assistant", "text": "Hi there!"} - ] - - result = conversation() - assert len(result) == 2 - assert result[0]["role"] == "user" -``` - -### Prompt validation - -```python -def validate_prompt_output(prompt_result): - """Validate prompt output structure.""" - if isinstance(prompt_result, str): - assert len(prompt_result.strip()) > 0, "Prompt should not be empty" - assert prompt_result.count("\\n") <= 50, "Prompt should not be excessively long" - elif isinstance(prompt_result, list): - assert len(prompt_result) > 0, "Multi-message prompt should have messages" - for message in prompt_result: - assert "role" in message or hasattr(message, "role"), "Messages need roles" - -@pytest.mark.parametrize("tone,recipient", [ - ("professional", "manager"), - ("casual", "colleague"), - ("formal", "client") -]) -def test_email_prompt_variations(tone, recipient): - mcp = FastMCP("Test") - - @mcp.prompt() - def email_prompt(recipient: str, tone: str) -> str: - return f"Write a {tone} email to {recipient}" - - result = email_prompt(recipient, tone) - validate_prompt_output(result) - assert tone in result - assert recipient in result -``` - -## Best practices - -### Design principles - -- **Clear purpose** - Each prompt should have a specific, well-defined goal -- **Flexible parameters** - Allow customization while maintaining structure -- **Comprehensive instructions** - Provide clear guidance for the LLM -- **Consistent format** - Use similar patterns across related prompts - -### Content guidelines - -- **Specific instructions** - Be explicit about what you want -- **Context provision** - Include relevant background information -- **Output specification** - Describe the expected response format -- **Examples inclusion** - Show examples when helpful - -### User experience - -- **Descriptive names** - Use clear, descriptive prompt names -- **Helpful descriptions** - Provide good docstrings -- **Sensible defaults** - Choose reasonable default parameter values -- **Progressive complexity** - Start simple, add complexity as needed - -## Common use cases - -### Content creation prompts -- Writing assistance and templates -- Creative writing generators -- Technical documentation helpers - -### Analysis and review prompts -- Code review templates -- Data analysis frameworks -- Research and evaluation guides - -### Communication prompts -- Email and message templates -- Meeting and presentation outlines -- Interview and conversation starters - -### Learning and training prompts -- Educational content generators -- Skill assessment frameworks -- Tutorial and guide templates - -## Next steps - -- **[Working with context](context.md)** - Access request context in prompts -- **[Server integration](servers.md)** - Combine prompts with tools and resources -- **[Client usage](writing-clients.md)** - How clients discover and use prompts -- **[Advanced patterns](structured-output.md)** - Complex prompt structures \ No newline at end of file diff --git a/docs/quickstart.md b/docs/quickstart.md deleted file mode 100644 index 3ba87f0a5..000000000 --- a/docs/quickstart.md +++ /dev/null @@ -1,140 +0,0 @@ -# Quickstart - -Get started with the MCP Python SDK in minutes by building a simple server that exposes tools, resources, and prompts. - -## Prerequisites - -- Python 3.10 or later -- [uv](https://docs.astral.sh/uv/) package manager - -## Create your first MCP server - -### 1. Set up your project - -Create a new project and add the MCP SDK: - -```bash -uv init my-mcp-server -cd my-mcp-server -uv add "mcp[cli]" -``` - -### 2. Create a simple server - -Create a file called `server.py`: - -```python -""" -Simple MCP server with tools, resources, and prompts. - -Run with: uv run mcp dev server.py -""" - -from mcp.server.fastmcp import FastMCP - -# Create an MCP server -mcp = FastMCP("Demo Server") - - -# Add a tool for mathematical operations -@mcp.tool() -def add(a: int, b: int) -> int: - """Add two numbers together.""" - return a + b - - -@mcp.tool() -def multiply(a: int, b: int) -> int: - """Multiply two numbers together.""" - return a * b - - -# Add a dynamic resource for greetings -@mcp.resource("greeting://{name}") -def get_greeting(name: str) -> str: - """Get a personalized greeting for someone.""" - return f"Hello, {name}! Welcome to our MCP server." - - -@mcp.resource("info://server") -def get_server_info() -> str: - """Get information about this server.""" - return """This is a demo MCP server that provides: - - Mathematical operations (add, multiply) - - Personalized greetings - - Server information - """ - - -# Add a prompt template -@mcp.prompt() -def greet_user(name: str, style: str = "friendly") -> str: - """Generate a greeting prompt for a user.""" - styles = { - "friendly": "Please write a warm, friendly greeting", - "formal": "Please write a formal, professional greeting", - "casual": "Please write a casual, relaxed greeting", - } - - style_instruction = styles.get(style, styles["friendly"]) - return f"{style_instruction} for someone named {name}." - - -if __name__ == "__main__": - # Run the server - mcp.run() -``` - -### 3. Test your server - -Test the server using the MCP Inspector: - -```bash -uv run mcp dev server.py -``` - -After installing any required dependencies, your default web browser should open the MCP Inspector where you can: - -- Call tools (`add` and `multiply`) -- Read resources (`greeting://YourName` and `info://server`) -- Use prompts (`greet_user`) - -### 4. Install in Claude Desktop - -Once you're happy with your server, install it in Claude Desktop: - -```bash -uv run mcp install server.py -``` - -Claude Desktop will now have access to your tools and resources! - -## What you've built - -Your server now provides: - -### Tools -- **add(a, b)** - Adds two numbers -- **multiply(a, b)** - Multiplies two numbers - -### Resources -- **greeting://{name}** - Personalized greetings (e.g., `greeting://Alice`) -- **info://server** - Server information - -### Prompts -- **greet_user** - Generates greeting prompts with different styles - -## Try these examples - -In the MCP Inspector or Claude Desktop, try: - -- Call the `add` tool: `{"a": 5, "b": 3}` → Returns `8` -- Read a greeting: `greeting://World` → Returns `"Hello, World! Welcome to our MCP server."` -- Use the greet_user prompt with `name: "Alice", style: "formal"` - -## Next steps - -- **[Learn about servers](servers.md)** - Understanding server lifecycle and configuration -- **[Explore tools](tools.md)** - Advanced tool patterns and structured output -- **[Working with resources](resources.md)** - Resource templates and patterns -- **[Running servers](running-servers.md)** - Development and production deployment options \ No newline at end of file diff --git a/docs/resources.md b/docs/resources.md deleted file mode 100644 index 923a6c2b8..000000000 --- a/docs/resources.md +++ /dev/null @@ -1,487 +0,0 @@ -# Resources - -Resources are how you expose data to LLMs through your MCP server. Think of them as GET endpoints that provide information without side effects. - -## What are resources? - -Resources provide data that LLMs can read to understand context. They should: - -- **Be read-only** - No side effects or state changes -- **Return data** - Text, JSON, or other content formats -- **Be fast** - Avoid expensive computations -- **Be cacheable** - Return consistent data for the same URI - -## Basic resource creation - -### Static resources - -```python -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP("Resource Example") - -@mcp.resource("config://settings") -def get_settings() -> str: - """Get application configuration.""" - return """ - { - "theme": "dark", - "language": "en", - "debug": false, - "timeout": 30 - } - """ - -@mcp.resource("info://version") -def get_version() -> str: - """Get application version information.""" - return "MyApp v1.2.3" -``` - -### Dynamic resources with parameters - -Use URI templates to create parameterized resources: - -```python -@mcp.resource("user://{user_id}") -def get_user(user_id: str) -> str: - """Get user information by ID.""" - # In a real application, you'd fetch from a database - users = { - "1": {"name": "Alice", "email": "alice@example.com", "role": "admin"}, - "2": {"name": "Bob", "email": "bob@example.com", "role": "user"}, - } - - user = users.get(user_id) - if not user: - raise ValueError(f"User {user_id} not found") - - return f""" - User ID: {user_id} - Name: {user['name']} - Email: {user['email']} - Role: {user['role']} - """ - -@mcp.resource("file://documents/{path}") -def read_document(path: str) -> str: - """Read a document by path.""" - # Security: validate path to prevent directory traversal - if ".." in path or path.startswith("/"): - raise ValueError("Invalid path") - - # In reality, you'd read from filesystem - documents = { - "readme.md": "# My Application\\n\\nWelcome to my app!", - "api.md": "# API Documentation\\n\\nEndpoints: ...", - } - - content = documents.get(path) - if not content: - raise ValueError(f"Document {path} not found") - - return content -``` - -## Advanced resource patterns - -### Database-backed resources - -```python -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from dataclasses import dataclass - -# Mock database class -class Database: - @classmethod - async def connect(cls) -> "Database": - return cls() - - async def disconnect(self) -> None: - pass - - async def get_product(self, product_id: str) -> dict | None: - # Simulate database query - products = { - "1": {"name": "Laptop", "price": 999.99, "stock": 10}, - "2": {"name": "Mouse", "price": 29.99, "stock": 50}, - } - return products.get(product_id) - - async def search_products(self, query: str) -> list[dict]: - # Simulate search - return [ - {"id": "1", "name": "Laptop", "price": 999.99}, - {"id": "2", "name": "Mouse", "price": 29.99}, - ] - -@dataclass -class AppContext: - db: Database - -@asynccontextmanager -async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: - db = await Database.connect() - try: - yield AppContext(db=db) - finally: - await db.disconnect() - -mcp = FastMCP("Product Server", lifespan=app_lifespan) - -@mcp.resource("product://{product_id}") -async def get_product(product_id: str, ctx: Context) -> str: - """Get detailed product information.""" - db = ctx.request_context.lifespan_context.db - - product = await db.get_product(product_id) - if not product: - raise ValueError(f"Product {product_id} not found") - - return f""" - Product: {product['name']} - Price: ${product['price']:.2f} - Stock: {product['stock']} units - """ - -@mcp.resource("products://search/{query}") -async def search_products(query: str, ctx: Context) -> str: - """Search for products.""" - db = ctx.request_context.lifespan_context.db - - products = await db.search_products(query) - - if not products: - return f"No products found for '{query}'" - - result = f"Search results for '{query}':\\n\\n" - for product in products: - result += f"- {product['name']} (${product['price']:.2f})\\n" - - return result -``` - -### File system resources - -```python -import os -from pathlib import Path - -@mcp.resource("files://{path}") -def read_file(path: str) -> str: - """Read a file from the allowed directory.""" - # Security: restrict to specific directory - base_dir = Path("/allowed/directory") - file_path = base_dir / path - - # Ensure path is within allowed directory - try: - file_path = file_path.resolve() - base_dir = base_dir.resolve() - if not str(file_path).startswith(str(base_dir)): - raise ValueError("Access denied: path outside allowed directory") - except OSError: - raise ValueError("Invalid file path") - - # Read file - try: - return file_path.read_text(encoding="utf-8") - except FileNotFoundError: - raise ValueError(f"File not found: {path}") - except PermissionError: - raise ValueError(f"Permission denied: {path}") - -@mcp.resource("directory://{path}") -def list_directory(path: str) -> str: - """List files in a directory.""" - base_dir = Path("/allowed/directory") - dir_path = base_dir / path - - # Security check (same as above) - try: - dir_path = dir_path.resolve() - base_dir = base_dir.resolve() - if not str(dir_path).startswith(str(base_dir)): - raise ValueError("Access denied") - except OSError: - raise ValueError("Invalid directory path") - - try: - entries = sorted(dir_path.iterdir()) - result = f"Contents of {path}:\\n\\n" - - for entry in entries: - if entry.is_dir(): - result += f"📁 {entry.name}/\\n" - else: - size = entry.stat().st_size - result += f"📄 {entry.name} ({size} bytes)\\n" - - return result - - except FileNotFoundError: - raise ValueError(f"Directory not found: {path}") - except PermissionError: - raise ValueError(f"Permission denied: {path}") -``` - -### API-backed resources - -```python -import aiohttp -import json - -@mcp.resource("weather://{city}") -async def get_weather(city: str) -> str: - """Get weather information for a city.""" - # In a real app, use a proper weather API - api_key = os.getenv("WEATHER_API_KEY") - if not api_key: - raise ValueError("Weather API key not configured") - - url = f"https://api.openweathermap.org/data/2.5/weather" - params = { - "q": city, - "appid": api_key, - "units": "metric" - } - - async with aiohttp.ClientSession() as session: - async with session.get(url, params=params) as response: - if response.status == 404: - raise ValueError(f"City '{city}' not found") - elif response.status != 200: - raise ValueError(f"Weather API error: {response.status}") - - data = await response.json() - - weather = data["weather"][0] - main = data["main"] - - return f""" - Weather in {city}: - Condition: {weather["description"].title()} - Temperature: {main["temp"]:.1f}°C - Feels like: {main["feels_like"]:.1f}°C - Humidity: {main["humidity"]}% - """ - -@mcp.resource("news://{category}") -async def get_news(category: str) -> str: - """Get news headlines for a category.""" - # Mock news API - news_data = { - "tech": [ - "New AI breakthrough announced", - "Major software update released", - "Tech company goes public" - ], - "sports": [ - "Championship game tonight", - "New record set in marathon", - "Team trades star player" - ] - } - - headlines = news_data.get(category.lower()) - if not headlines: - raise ValueError(f"Category '{category}' not found") - - result = f"Latest {category} news:\\n\\n" - for i, headline in enumerate(headlines, 1): - result += f"{i}. {headline}\\n" - - return result -``` - -## Resource patterns and best practices - -### Structured data resources - -Return JSON for complex data structures: - -```python -import json - -@mcp.resource("api://users/{user_id}/profile") -def get_user_profile(user_id: str) -> str: - """Get structured user profile data.""" - # Simulate database lookup - profile = { - "user_id": user_id, - "profile": { - "name": "Alice Johnson", - "email": "alice@example.com", - "preferences": { - "theme": "dark", - "language": "en", - "notifications": True - }, - "stats": { - "posts_count": 42, - "followers": 156, - "following": 89 - } - } - } - - return json.dumps(profile, indent=2) -``` - -### Error handling - -Provide clear error messages: - -```python -@mcp.resource("data://{dataset}/{record_id}") -def get_record(dataset: str, record_id: str) -> str: - """Get a record from a dataset.""" - # Validate dataset - allowed_datasets = ["users", "products", "orders"] - if dataset not in allowed_datasets: - raise ValueError(f"Dataset '{dataset}' not found. Available: {', '.join(allowed_datasets)}") - - # Validate record ID format - if not record_id.isdigit(): - raise ValueError("Record ID must be a number") - - # Simulate record lookup - if int(record_id) > 1000: - raise ValueError(f"Record {record_id} not found in dataset '{dataset}'") - - return f"Record {record_id} from {dataset} dataset" -``` - -### Resource templates - -Use resource templates to help clients discover available resources: - -```python -# The @mcp.resource decorator automatically creates resource templates -# For "user://{user_id}", MCP creates a template that clients can discover - -# You can also list available values programmatically -@mcp.resource("datasets://list") -def list_datasets() -> str: - """List all available datasets.""" - datasets = ["users", "products", "orders", "analytics"] - return "Available datasets:\\n" + "\\n".join(f"- {ds}" for ds in datasets) - -@mcp.resource("users://list") -def list_users() -> str: - """List all user IDs.""" - # In reality, this would query your database - user_ids = ["1", "2", "3", "42", "100"] - return "Available user IDs:\\n" + "\\n".join(f"- {uid}" for uid in user_ids) -``` - -## Security considerations - -### Input validation - -Always validate resource parameters: - -```python -import re - -@mcp.resource("secure://data/{identifier}") -def get_secure_data(identifier: str) -> str: - """Get data with security validation.""" - # Validate identifier format - if not re.match(r"^[a-zA-Z0-9_-]+$", identifier): - raise ValueError("Invalid identifier format") - - # Check length limits - if len(identifier) > 50: - raise ValueError("Identifier too long") - - # Additional security checks... - return f"Secure data for {identifier}" -``` - -### Access control - -```python -@mcp.resource("private://{resource_id}") -async def get_private_resource(resource_id: str, ctx: Context) -> str: - """Get private resource with access control.""" - # Check if user is authenticated (in a real app) - # This would typically come from JWT token or session - user_role = getattr(ctx.session, "user_role", None) - - if user_role != "admin": - raise ValueError("Access denied: admin role required") - - return f"Private resource {resource_id} - only for admins" -``` - -## Testing resources - -### Unit testing - -```python -import pytest -from mcp.server.fastmcp import FastMCP - -def test_static_resource(): - mcp = FastMCP("Test") - - @mcp.resource("test://data") - def get_data() -> str: - return "test data" - - result = get_data() - assert result == "test data" - -def test_dynamic_resource(): - mcp = FastMCP("Test") - - @mcp.resource("test://user/{user_id}") - def get_user(user_id: str) -> str: - return f"User {user_id}" - - result = get_user("123") - assert result == "User 123" - -def test_resource_error_handling(): - mcp = FastMCP("Test") - - @mcp.resource("test://item/{item_id}") - def get_item(item_id: str) -> str: - if item_id == "404": - raise ValueError("Item not found") - return f"Item {item_id}" - - with pytest.raises(ValueError, match="Item not found"): - get_item("404") -``` - -## Common use cases - -### Configuration resources -- Application settings -- Environment variables -- Feature flags - -### Data resources -- User profiles -- Product catalogs -- Content management - -### Status resources -- System health -- Application metrics -- Service status - -### Documentation resources -- API documentation -- Help content -- Schema definitions - -## Next steps - -- **[Learn about tools](tools.md)** - Create interactive functions -- **[Working with context](context.md)** - Access request information -- **[Server patterns](servers.md)** - Advanced server configurations -- **[Client integration](writing-clients.md)** - How clients consume resources \ No newline at end of file diff --git a/docs/running-servers.md b/docs/running-servers.md deleted file mode 100644 index eb688653f..000000000 --- a/docs/running-servers.md +++ /dev/null @@ -1,666 +0,0 @@ -# Running servers - -Learn the different ways to run your MCP servers: development mode with the MCP Inspector, integration with Claude Desktop, direct execution, and production deployment. - -## Development mode - -### MCP Inspector - -The fastest way to test and debug your server is with the built-in MCP Inspector: - -```bash -# Basic usage -uv run mcp dev server.py - -# With additional dependencies -uv run mcp dev server.py --with pandas --with numpy - -# Mount local code as editable -uv run mcp dev server.py --with-editable . - -# Custom port -uv run mcp dev server.py --port 8001 -``` - -The MCP Inspector provides: - -- **Interactive web interface** - Test tools, resources, and prompts -- **Real-time logging** - See all server logs and debug information -- **Request/response inspection** - Debug MCP protocol messages -- **Auto-reload** - Automatically restart when code changes -- **Dependency management** - Install packages on-the-fly - -### Development server example - -```python -\"\"\" -Development server with comprehensive features. - -Run with: uv run mcp dev development_server.py -\"\"\" - -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - -# Create server with debug settings -mcp = FastMCP( - \"Development Server\", - debug=True, - log_level=\"DEBUG\" -) - -@mcp.tool() -async def debug_info(ctx: Context[ServerSession, None]) -> dict: - \"\"\"Get debug information about the server and request.\"\"\" - await ctx.debug(\"Debug info requested\") - - return { - \"server\": { - \"name\": ctx.fastmcp.name, - \"debug_mode\": ctx.fastmcp.settings.debug, - \"log_level\": ctx.fastmcp.settings.log_level - }, - \"request\": { - \"request_id\": ctx.request_id, - \"client_id\": ctx.client_id - } - } - -@mcp.resource(\"dev://logs/{level}\") -def get_logs(level: str) -> str: - \"\"\"Get simulated log entries for development.\"\"\" - logs = { - \"info\": \"2024-01-01 10:00:00 INFO: Server started\\n2024-01-01 10:01:00 INFO: Client connected\", - \"debug\": \"2024-01-01 10:00:00 DEBUG: Initializing server\\n2024-01-01 10:00:01 DEBUG: Loading configuration\", - \"error\": \"2024-01-01 10:02:00 ERROR: Failed to process request\\n2024-01-01 10:02:01 ERROR: Database connection lost\" - } - return logs.get(level, \"No logs found for level: \" + level) - -if __name__ == \"__main__\": - # Run with development settings - mcp.run() -``` - -## Claude Desktop integration - -### Installing servers - -Install your server in Claude Desktop for production use: - -```bash -# Basic installation -uv run mcp install server.py - -# Custom server name -uv run mcp install server.py --name \"My Analytics Server\" - -# With environment variables -uv run mcp install server.py -v API_KEY=abc123 -v DB_URL=postgres://localhost/myapp - -# From environment file -uv run mcp install server.py -f .env - -# Specify custom port -uv run mcp install server.py --port 8080 -``` - -### Example production server - -```python -\"\"\" -Production-ready MCP server for Claude Desktop. - -Install with: uv run mcp install production_server.py --name \"Analytics Server\" -\"\"\" - -import os -from mcp.server.fastmcp import FastMCP - -# Create production server -mcp = FastMCP( - \"Analytics Server\", - instructions=\"Provides data analytics and business intelligence tools\", - debug=False, # Disable debug mode for production - log_level=\"INFO\" -) - -@mcp.tool() -def calculate_metrics(data: list[float]) -> dict[str, float]: - \"\"\"Calculate key metrics from numerical data.\"\"\" - if not data: - raise ValueError(\"Data cannot be empty\") - - return { - \"count\": len(data), - \"mean\": sum(data) / len(data), - \"min\": min(data), - \"max\": max(data), - \"sum\": sum(data) - } - -@mcp.resource(\"config://database\") -def get_database_config() -> str: - \"\"\"Get database configuration from environment.\"\"\" - db_url = os.getenv(\"DB_URL\", \"sqlite:///default.db\") - return f\"Database URL: {db_url}\" - -@mcp.prompt() -def analyze_data(dataset_name: str, analysis_type: str = \"summary\") -> str: - \"\"\"Generate data analysis prompt.\"\"\" - return f\"\"\"Please analyze the {dataset_name} dataset. - -Analysis type: {analysis_type} - -Provide: -1. Key insights and trends -2. Notable patterns or anomalies -3. Actionable recommendations -4. Data quality assessment -\"\"\" - -if __name__ == \"__main__\": - mcp.run() -``` - -### Environment configuration - -Create a `.env` file for environment variables: - -```bash -# .env file for MCP server -DB_URL=postgresql://user:pass@localhost/analytics -API_KEY=your-secret-api-key -REDIS_URL=redis://localhost:6379/0 -LOG_LEVEL=INFO -DEBUG_MODE=false -``` - -## Direct execution - -### Simple execution - -Run servers directly for custom deployments: - -```python -\"\"\" -Direct execution example. - -Run with: python direct_server.py -\"\"\" - -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP(\"Direct Server\") - -@mcp.tool() -def hello(name: str = \"World\") -> str: - \"\"\"Say hello to someone.\"\"\" - return f\"Hello, {name}!\" - -def main(): - \"\"\"Entry point for direct execution.\"\"\" - # Run with default transport (stdio) - mcp.run() - -if __name__ == \"__main__\": - main() -``` - -### Command-line arguments - -Add CLI support for flexible execution: - -```python -\"\"\" -Server with command-line interface. - -Run with: python cli_server.py --port 8080 --debug -\"\"\" - -import argparse -from mcp.server.fastmcp import FastMCP - -def create_server(debug: bool = False, log_level: str = \"INFO\") -> FastMCP: - \"\"\"Create server with configuration.\"\"\" - return FastMCP( - \"CLI Server\", - debug=debug, - log_level=log_level - ) - -def main(): - \"\"\"Main entry point with argument parsing.\"\"\" - parser = argparse.ArgumentParser(description=\"MCP Server with CLI\") - parser.add_argument(\"--port\", type=int, default=8000, help=\"Server port\") - parser.add_argument(\"--host\", default=\"localhost\", help=\"Server host\") - parser.add_argument(\"--debug\", action=\"store_true\", help=\"Enable debug mode\") - parser.add_argument(\"--log-level\", choices=[\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\"], - default=\"INFO\", help=\"Log level\") - parser.add_argument(\"--transport\", choices=[\"stdio\", \"sse\", \"streamable-http\"], - default=\"stdio\", help=\"Transport type\") - - args = parser.parse_args() - - # Create server with parsed arguments - mcp = create_server(debug=args.debug, log_level=args.log_level) - - @mcp.tool() - def get_server_config() -> dict: - \"\"\"Get current server configuration.\"\"\" - return { - \"host\": args.host, - \"port\": args.port, - \"debug\": args.debug, - \"log_level\": args.log_level, - \"transport\": args.transport - } - - # Run with specified configuration - mcp.run( - transport=args.transport, - host=args.host, - port=args.port - ) - -if __name__ == \"__main__\": - main() -``` - -## Transport options - -### stdio transport (default) - -Best for Claude Desktop integration and command-line tools: - -```python -# Run with stdio (default) -mcp.run() # or mcp.run(transport=\"stdio\") -``` - -### HTTP transports - -#### SSE (Server-Sent Events) - -```python -# Run with SSE transport -mcp.run(transport=\"sse\", host=\"0.0.0.0\", port=8000) -``` - -#### Streamable HTTP (recommended for production) - -```python -# Run with Streamable HTTP transport -mcp.run(transport=\"streamable-http\", host=\"0.0.0.0\", port=8000) - -# With stateless configuration (better for scaling) -mcp = FastMCP(\"Stateless Server\", stateless_http=True) -mcp.run(transport=\"streamable-http\") -``` - -### Transport comparison - -| Transport | Best for | Pros | Cons | -|-----------|----------|------|------| -| **stdio** | Claude Desktop, CLI tools | Simple, reliable | Not web-accessible | -| **SSE** | Web integration, streaming | Real-time updates | Being superseded | -| **Streamable HTTP** | Production, scaling | Stateful/stateless, resumable | More complex | - -## Production deployment - -### Docker deployment - -Create a `Dockerfile`: - -```dockerfile -FROM python:3.11-slim - -WORKDIR /app - -# Install uv -RUN pip install uv - -# Copy project files -COPY pyproject.toml uv.lock ./ -COPY src/ src/ - -# Install dependencies -RUN uv sync --frozen - -# Copy server code -COPY server.py . - -# Expose port -EXPOSE 8000 - -# Run server -CMD [\"uv\", \"run\", \"python\", \"server.py\", \"--transport\", \"streamable-http\", \"--host\", \"0.0.0.0\", \"--port\", \"8000\"] -``` - -Build and run: - -```bash -# Build image -docker build -t my-mcp-server . - -# Run container -docker run -p 8000:8000 -e API_KEY=secret my-mcp-server -``` - -### Docker Compose - -Create `docker-compose.yml`: - -```yaml -version: '3.8' - -services: - mcp-server: - build: . - ports: - - \"8000:8000\" - environment: - - API_KEY=${API_KEY} - - DB_URL=postgresql://postgres:password@db:5432/myapp - - REDIS_URL=redis://redis:6379/0 - depends_on: - - db - - redis - restart: unless-stopped - - db: - image: postgres:15 - environment: - POSTGRES_DB: myapp - POSTGRES_USER: postgres - POSTGRES_PASSWORD: password - volumes: - - postgres_data:/var/lib/postgresql/data - restart: unless-stopped - - redis: - image: redis:7-alpine - restart: unless-stopped - -volumes: - postgres_data: -``` - -### Process management with systemd - -Create `/etc/systemd/system/mcp-server.service`: - -```ini -[Unit] -Description=MCP Server -After=network.target - -[Service] -Type=simple -User=mcp -WorkingDirectory=/opt/mcp-server -ExecStart=/opt/mcp-server/.venv/bin/python server.py --transport streamable-http --host 0.0.0.0 --port 8000 -Restart=always -RestartSec=5 -Environment=PATH=/opt/mcp-server/.venv/bin -EnvironmentFile=/opt/mcp-server/.env - -[Install] -WantedBy=multi-user.target -``` - -Enable and start: - -```bash -sudo systemctl enable mcp-server -sudo systemctl start mcp-server -sudo systemctl status mcp-server -``` - -### Reverse proxy with nginx - -Create `/etc/nginx/sites-available/mcp-server`: - -```nginx -server { - listen 80; - server_name your-domain.com; - - location / { - proxy_pass http://localhost:8000; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection \"upgrade\"; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # For Server-Sent Events - proxy_buffering off; - proxy_cache off; - } -} -``` - -## Monitoring and health checks - -### Health check endpoint - -```python -@mcp.tool() -async def health_check() -> dict: - \"\"\"Server health check endpoint.\"\"\" - import time - import psutil - - return { - \"status\": \"healthy\", - \"timestamp\": time.time(), - \"uptime\": time.time() - server_start_time, - \"memory_usage\": psutil.Process().memory_info().rss / 1024 / 1024, # MB - \"cpu_percent\": psutil.Process().cpu_percent() - } -``` - -### Logging configuration - -```python -import logging -import sys - -def setup_logging(log_level: str = \"INFO\", log_file: str | None = None): - \"\"\"Configure logging for production.\"\"\" - handlers = [logging.StreamHandler(sys.stdout)] - - if log_file: - handlers.append(logging.FileHandler(log_file)) - - logging.basicConfig( - level=getattr(logging, log_level), - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=handlers - ) - -# Use in production server -if __name__ == \"__main__\": - setup_logging(log_level=\"INFO\", log_file=\"/var/log/mcp-server.log\") - mcp.run() -``` - -### Process monitoring - -Monitor your server with tools like: - -- **Supervisor** - Process management and auto-restart -- **PM2** - Node.js process manager (works with Python too) -- **systemd** - System service management -- **Docker health checks** - Container health monitoring - -Example supervisor config: - -```ini -[program:mcp-server] -command=/opt/mcp-server/.venv/bin/python server.py -directory=/opt/mcp-server -user=mcp -autostart=true -autorestart=true -redirect_stderr=true -stdout_logfile=/var/log/mcp-server.log -environment=PATH=/opt/mcp-server/.venv/bin -``` - -## Performance optimization - -### Server configuration - -```python -# Optimized production server -mcp = FastMCP( - \"Production Server\", - debug=False, # Disable debug mode - log_level=\"INFO\", # Reduce log verbosity - stateless_http=True, # Enable stateless mode for scaling - host=\"0.0.0.0\", # Accept connections from any host - port=8000 -) -``` - -### Resource management - -```python -from contextlib import asynccontextmanager -from collections.abc import AsyncIterator -import aioredis -import asyncpg - -@dataclass -class AppContext: - db_pool: asyncpg.Pool - redis: aioredis.Redis - -@asynccontextmanager -async def optimized_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: - \"\"\"Optimized lifespan with connection pooling.\"\"\" - # Create connection pools - db_pool = await asyncpg.create_pool( - \"postgresql://user:pass@localhost/db\", - min_size=5, - max_size=20 - ) - - redis = aioredis.from_url( - \"redis://localhost:6379\", - encoding=\"utf-8\", - decode_responses=True - ) - - try: - yield AppContext(db_pool=db_pool, redis=redis) - finally: - await db_pool.close() - await redis.close() - -mcp = FastMCP(\"Optimized Server\", lifespan=optimized_lifespan) -``` - -## Troubleshooting - -### Common issues - -**Server not starting:** -```bash -# Check if port is in use -lsof -i :8000 - -# Check server logs -uv run mcp dev server.py --log-level DEBUG -``` - -**Claude Desktop not connecting:** -```bash -# Verify installation -uv run mcp list - -# Test server manually -uv run mcp dev server.py - -# Check Claude Desktop logs (macOS) -tail -f ~/Library/Logs/Claude/mcp-server.log -``` - -**Performance issues:** -```bash -# Monitor resource usage -htop - -# Check connection limits -ulimit -n - -# Profile Python code -python -m cProfile server.py -``` - -### Debug tools - -```python -@mcp.tool() -async def debug_server(ctx: Context) -> dict: - \"\"\"Get comprehensive debug information.\"\"\" - import platform - import sys - import os - - return { - \"python\": { - \"version\": sys.version, - \"executable\": sys.executable, - \"platform\": platform.platform() - }, - \"environment\": { - \"cwd\": os.getcwd(), - \"env_vars\": dict(os.environ) - }, - \"server\": { - \"name\": ctx.fastmcp.name, - \"settings\": ctx.fastmcp.settings.__dict__ - }, - \"request\": { - \"id\": ctx.request_id, - \"client_id\": ctx.client_id - } - } -``` - -## Best practices - -### Development workflow - -1. **Start with MCP Inspector** - Use `mcp dev` for rapid iteration -2. **Test with Claude Desktop** - Install and test real-world usage -3. **Add environment configuration** - Use `.env` files for settings -4. **Implement health checks** - Add monitoring and debugging tools -5. **Plan deployment** - Choose appropriate transport and hosting - -### Production readiness - -- **Error handling** - Comprehensive error handling and recovery -- **Logging** - Structured logging with appropriate levels -- **Security** - Authentication, input validation, and rate limiting -- **Monitoring** - Health checks, metrics, and alerting -- **Scaling** - Connection pooling, stateless design, and load balancing - -### Security considerations - -- **Input validation** - Validate all tool and resource parameters -- **Environment variables** - Store secrets in environment, not code -- **Network security** - Use HTTPS in production, restrict access -- **Rate limiting** - Prevent abuse and resource exhaustion -- **Authentication** - Implement proper authentication for sensitive operations - -## Next steps - -- **[Streamable HTTP](streamable-http.md)** - Modern HTTP transport details -- **[ASGI integration](asgi-integration.md)** - Integrate with web frameworks -- **[Authentication](authentication.md)** - Secure your production servers -- **[Client development](writing-clients.md)** - Build clients to connect to your servers \ No newline at end of file diff --git a/docs/sampling.md b/docs/sampling.md deleted file mode 100644 index 92d7bb1a3..000000000 --- a/docs/sampling.md +++ /dev/null @@ -1,628 +0,0 @@ -# Sampling - -Sampling allows MCP servers to interact with LLMs by requesting text generation. This enables servers to leverage LLM capabilities within their tools and workflows. - -## What is sampling? - -Sampling enables servers to: - -- **Generate text** - Request LLM text completion -- **Interactive workflows** - Create multi-step conversations -- **Content creation** - Generate dynamic content based on data -- **Decision making** - Use LLM reasoning in server logic - -## Basic sampling - -### Simple text generation - -```python -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession -from mcp.types import SamplingMessage, TextContent - -mcp = FastMCP("Sampling Example") - -@mcp.tool() -async def generate_summary(text: str, ctx: Context[ServerSession, None]) -> str: - """Generate a summary using LLM sampling.""" - prompt = f"Please provide a concise summary of the following text:\\n\\n{text}" - - result = await ctx.session.create_message( - messages=[ - SamplingMessage( - role="user", - content=TextContent(type="text", text=prompt) - ) - ], - max_tokens=150 - ) - - if result.content.type == "text": - return result.content.text - return str(result.content) - -@mcp.tool() -async def creative_writing(topic: str, style: str, ctx: Context) -> str: - """Generate creative content with specific style.""" - prompt = f"Write a short {style} piece about {topic}. Be creative and engaging." - - result = await ctx.session.create_message( - messages=[ - SamplingMessage( - role="user", - content=TextContent(type="text", text=prompt) - ) - ], - max_tokens=300 - ) - - return result.content.text if result.content.type == "text" else str(result.content) -``` - -### Conversational sampling - -```python -@mcp.tool() -async def interactive_advisor( - user_question: str, - context: str, - ctx: Context[ServerSession, None] -) -> str: - """Provide interactive advice using conversation.""" - messages = [ - SamplingMessage( - role="system", - content=TextContent( - type="text", - text=f"You are a helpful advisor. Context: {context}" - ) - ), - SamplingMessage( - role="user", - content=TextContent(type="text", text=user_question) - ) - ] - - result = await ctx.session.create_message( - messages=messages, - max_tokens=200, - temperature=0.7 # Add some creativity - ) - - return result.content.text if result.content.type == "text" else "Unable to generate response" -``` - -## Advanced sampling patterns - -### Multi-turn conversations - -```python -@mcp.tool() -async def research_assistant( - topic: str, - depth: str = "overview", - ctx: Context[ServerSession, None] -) -> dict[str, str]: - """Conduct research using multi-turn conversation.""" - - # First, ask for an outline - outline_result = await ctx.session.create_message( - messages=[ - SamplingMessage( - role="user", - content=TextContent( - type="text", - text=f"Create a research outline for the topic: {topic}. " - f"Depth level: {depth}. Provide 3-5 main points." - ) - ) - ], - max_tokens=200 - ) - - outline = outline_result.content.text if outline_result.content.type == "text" else "" - - # Then expand on each point - expansion_result = await ctx.session.create_message( - messages=[ - SamplingMessage(role="user", content=TextContent(type="text", text=f"Based on this outline:\\n{outline}\\n\\nProvide detailed explanations for each main point about {topic}.")), - ], - max_tokens=500 - ) - - expansion = expansion_result.content.text if expansion_result.content.type == "text" else "" - - return { - "topic": topic, - "outline": outline, - "detailed_analysis": expansion - } - -@mcp.tool() -async def brainstorm_solutions( - problem: str, - constraints: list[str], - ctx: Context[ServerSession, None] -) -> dict: - """Brainstorm solutions through iterative sampling.""" - - # Generate initial ideas - constraints_text = "\\n- ".join(constraints) if constraints else "None specified" - - initial_ideas = await ctx.session.create_message( - messages=[ - SamplingMessage( - role="user", - content=TextContent( - type="text", - text=f"Brainstorm 5 creative solutions for this problem: {problem}\\n\\nConstraints:\\n- {constraints_text}" - ) - ) - ], - max_tokens=300 - ) - - ideas = initial_ideas.content.text if initial_ideas.content.type == "text" else "" - - # Evaluate and refine ideas - evaluation = await ctx.session.create_message( - messages=[ - SamplingMessage( - role="user", - content=TextContent( - type="text", - text=f"Evaluate these solutions for the problem '{problem}':\\n\\n{ideas}\\n\\nRank them by feasibility and effectiveness. Suggest improvements for the top 2 solutions." - ) - ) - ], - max_tokens=400 - ) - - eval_text = evaluation.content.text if evaluation.content.type == "text" else "" - - return { - "problem": problem, - "constraints": constraints, - "initial_ideas": ideas, - "evaluation_and_refinement": eval_text - } -``` - -### Data-driven sampling - -```python -@mcp.tool() -async def analyze_data_with_llm( - data: dict, - analysis_type: str, - ctx: Context[ServerSession, None] -) -> str: - """Analyze data using LLM reasoning.""" - - # Convert data to readable format - data_summary = "\\n".join([f"- {k}: {v}" for k, v in data.items()]) - - analysis_prompts = { - "trends": f"Analyze the following data for trends and patterns:\\n{data_summary}\\n\\nWhat trends do you observe? What might be causing them?", - "insights": f"Provide business insights from this data:\\n{data_summary}\\n\\nWhat insights can help improve decision making?", - "recommendations": f"Based on this data:\\n{data_summary}\\n\\nWhat are your top 3 recommendations for action?" - } - - prompt = analysis_prompts.get(analysis_type, analysis_prompts["insights"]) - - result = await ctx.session.create_message( - messages=[ - SamplingMessage( - role="user", - content=TextContent(type="text", text=prompt) - ) - ], - max_tokens=400 - ) - - return result.content.text if result.content.type == "text" else "Analysis unavailable" - -@mcp.tool() -async def generate_report( - data_points: list[dict], - report_type: str, - ctx: Context[ServerSession, None] -) -> str: - """Generate formatted reports using sampling.""" - - # Prepare data summary - summary_lines = [] - for i, point in enumerate(data_points, 1): - summary_lines.append(f"{i}. {point}") - - data_text = "\\n".join(summary_lines) - - report_prompts = { - "executive": f"Create an executive summary report from this data:\\n{data_text}\\n\\nFormat: Title, Key Findings (3-4 bullet points), Recommendations", - "detailed": f"Create a detailed analysis report from this data:\\n{data_text}\\n\\nInclude: Introduction, Methodology, Findings, Analysis, Conclusions", - "technical": f"Create a technical report from this data:\\n{data_text}\\n\\nFocus on: Data Quality, Statistical Analysis, Technical Findings, Implementation Notes" - } - - prompt = report_prompts.get(report_type, report_prompts["executive"]) - - result = await ctx.session.create_message( - messages=[ - SamplingMessage( - role="user", - content=TextContent(type="text", text=prompt) - ) - ], - max_tokens=600 - ) - - return result.content.text if result.content.type == "text" else "Report generation failed" -``` - -## Sampling with context - -### Using server data in sampling - -```python -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from dataclasses import dataclass - -@dataclass -class KnowledgeBase: - """Mock knowledge base.""" - - def get_context(self, topic: str) -> str: - knowledge = { - "python": "Python is a high-level programming language known for readability and versatility.", - "ai": "Artificial Intelligence involves creating systems that can perform tasks requiring human intelligence.", - "web": "Web development involves creating websites and web applications using various technologies." - } - return knowledge.get(topic.lower(), "No specific knowledge available for this topic.") - -@dataclass -class AppContext: - knowledge_base: KnowledgeBase - -@asynccontextmanager -async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: - kb = KnowledgeBase() - yield AppContext(knowledge_base=kb) - -mcp = FastMCP("Knowledge Assistant", lifespan=app_lifespan) - -@mcp.tool() -async def expert_advice( - question: str, - topic: str, - ctx: Context[ServerSession, AppContext] -) -> str: - """Provide expert advice using knowledge base context.""" - - # Get relevant context from knowledge base - kb = ctx.request_context.lifespan_context.knowledge_base - context_info = kb.get_context(topic) - - # Create enhanced prompt with context - prompt = f"""Context: {context_info} - -Question: {question} - -Please provide expert advice based on the context provided above. If the context doesn't fully cover the question, acknowledge the limitations and provide what guidance you can.""" - - result = await ctx.session.create_message( - messages=[ - SamplingMessage( - role="user", - content=TextContent(type="text", text=prompt) - ) - ], - max_tokens=300 - ) - - advice = result.content.text if result.content.type == "text" else "Unable to provide advice" - - await ctx.info(f"Expert advice provided for topic: {topic}") - - return advice -``` - -### Resource-informed sampling - -```python -@mcp.resource("knowledge://{domain}") -def get_knowledge(domain: str) -> str: - """Get knowledge about a domain.""" - knowledge_db = { - "marketing": "Marketing involves promoting products/services through various channels...", - "finance": "Finance deals with money management, investments, and financial planning...", - "technology": "Technology encompasses computing, software, hardware, and digital systems..." - } - return knowledge_db.get(domain, "No knowledge available for this domain") - -@mcp.tool() -async def contextual_answer( - question: str, - domain: str, - ctx: Context[ServerSession, None] -) -> str: - """Answer questions using domain knowledge from resources.""" - - try: - # Read domain knowledge from resource - knowledge_resource = await ctx.read_resource(f"knowledge://{domain}") - - if knowledge_resource.contents: - content = knowledge_resource.contents[0] - domain_knowledge = content.text if hasattr(content, 'text') else "" - - prompt = f"""Domain Knowledge: {domain_knowledge} - -Question: {question} - -Please answer the question using the domain knowledge provided above. Be specific and reference the knowledge when relevant.""" - - result = await ctx.session.create_message( - messages=[ - SamplingMessage( - role="user", - content=TextContent(type="text", text=prompt) - ) - ], - max_tokens=250 - ) - - return result.content.text if result.content.type == "text" else "Unable to generate answer" - - except Exception as e: - await ctx.error(f"Failed to read domain knowledge: {e}") - - # Fallback to general answer - result = await ctx.session.create_message( - messages=[ - SamplingMessage( - role="user", - content=TextContent(type="text", text=question) - ) - ], - max_tokens=200 - ) - - return result.content.text if result.content.type == "text" else "Unable to provide answer" -``` - -## Error handling and best practices - -### Robust sampling implementation - -```python -@mcp.tool() -async def robust_generation( - prompt: str, - ctx: Context[ServerSession, None], - max_retries: int = 3 -) -> dict[str, any]: - """Generate text with error handling and retries.""" - - for attempt in range(max_retries): - try: - await ctx.debug(f"Generation attempt {attempt + 1}/{max_retries}") - - result = await ctx.session.create_message( - messages=[ - SamplingMessage( - role="user", - content=TextContent(type="text", text=prompt) - ) - ], - max_tokens=200 - ) - - if result.content.type == "text" and result.content.text.strip(): - await ctx.info("Text generation successful") - return { - "success": True, - "content": result.content.text, - "attempts": attempt + 1 - } - else: - await ctx.warning(f"Empty response on attempt {attempt + 1}") - - except Exception as e: - await ctx.warning(f"Generation failed on attempt {attempt + 1}: {e}") - if attempt == max_retries - 1: # Last attempt - await ctx.error("All generation attempts failed") - return { - "success": False, - "error": str(e), - "attempts": max_retries - } - - return { - "success": False, - "error": "Maximum retries exceeded", - "attempts": max_retries - } - -@mcp.tool() -async def safe_sampling( - user_input: str, - ctx: Context[ServerSession, None] -) -> str: - """Safe sampling with input validation and output filtering.""" - - # Input validation - if len(user_input) > 1000: - raise ValueError("Input too long (max 1000 characters)") - - if not user_input.strip(): - raise ValueError("Empty input not allowed") - - # Content filtering for prompt injection - suspicious_patterns = ["ignore previous", "system:", "assistant:", "role:"] - user_input_lower = user_input.lower() - - for pattern in suspicious_patterns: - if pattern in user_input_lower: - await ctx.warning(f"Suspicious pattern detected: {pattern}") - raise ValueError("Input contains potentially harmful content") - - try: - result = await ctx.session.create_message( - messages=[ - SamplingMessage( - role="user", - content=TextContent(type="text", text=f"Please respond to: {user_input}") - ) - ], - max_tokens=150 - ) - - response = result.content.text if result.content.type == "text" else "" - - # Output validation - if not response or len(response.strip()) < 10: - await ctx.warning("Generated response too short") - return "Unable to generate meaningful response" - - return response - - except Exception as e: - await ctx.error(f"Sampling failed: {e}") - return "Text generation service unavailable" -``` - -## Performance optimization - -### Caching and batching - -```python -from functools import lru_cache -import hashlib - -class SamplingCache: - """Simple cache for sampling results.""" - - def __init__(self, max_size: int = 100): - self.cache = {} - self.max_size = max_size - - def get_key(self, messages: list, max_tokens: int) -> str: - """Generate cache key from messages and parameters.""" - content = str(messages) + str(max_tokens) - return hashlib.md5(content.encode()).hexdigest() - - def get(self, key: str) -> str | None: - return self.cache.get(key) - - def set(self, key: str, value: str): - if len(self.cache) >= self.max_size: - # Simple LRU: remove oldest entry - oldest_key = next(iter(self.cache)) - del self.cache[oldest_key] - self.cache[key] = value - -# Global cache instance -sampling_cache = SamplingCache() - -@mcp.tool() -async def cached_generation( - prompt: str, - ctx: Context[ServerSession, None] -) -> str: - """Generate text with caching for repeated prompts.""" - - messages = [SamplingMessage(role="user", content=TextContent(type="text", text=prompt))] - max_tokens = 200 - - # Check cache first - cache_key = sampling_cache.get_key(messages, max_tokens) - cached_result = sampling_cache.get(cache_key) - - if cached_result: - await ctx.debug("Returning cached result") - return cached_result - - # Generate new response - result = await ctx.session.create_message( - messages=messages, - max_tokens=max_tokens - ) - - response = result.content.text if result.content.type == "text" else "" - - # Cache the result - sampling_cache.set(cache_key, response) - await ctx.debug("Result cached for future use") - - return response -``` - -## Testing sampling functionality - -### Unit testing with mocks - -```python -import pytest -from unittest.mock import AsyncMock, Mock - -@pytest.mark.asyncio -async def test_sampling_tool(): - """Test sampling tool with mocked session.""" - - # Mock session and result - mock_session = AsyncMock() - mock_result = Mock() - mock_result.content.type = "text" - mock_result.content.text = "Generated response" - - mock_session.create_message.return_value = mock_result - - # Mock context - mock_ctx = Mock() - mock_ctx.session = mock_session - - # Test the function - @mcp.tool() - async def test_generation(prompt: str, ctx: Context) -> str: - result = await ctx.session.create_message( - messages=[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))], - max_tokens=100 - ) - return result.content.text - - result = await test_generation("test prompt", mock_ctx) - - assert result == "Generated response" - mock_session.create_message.assert_called_once() -``` - -## Best practices - -### Sampling guidelines - -- **Validate inputs** - Always sanitize user input before sampling -- **Handle errors gracefully** - Implement retries and fallbacks -- **Use appropriate max_tokens** - Balance response quality and cost -- **Cache results** - Cache expensive operations when appropriate -- **Monitor usage** - Track sampling costs and performance - -### Security considerations - -- **Prompt injection prevention** - Filter suspicious input patterns -- **Output validation** - Verify generated content is appropriate -- **Rate limiting** - Prevent abuse of expensive sampling operations -- **Content filtering** - Remove sensitive information from responses - -### Performance tips - -- **Batch operations** - Combine multiple sampling requests when possible -- **Optimize prompts** - Use clear, concise prompts for better results -- **Set reasonable limits** - Use appropriate token limits and timeouts -- **Cache intelligently** - Cache expensive computations and common queries - -## Next steps - -- **[Context usage](context.md)** - Advanced context patterns with sampling -- **[Elicitation](elicitation.md)** - Interactive user input collection -- **[Progress reporting](progress-logging.md)** - Progress updates during long sampling -- **[Authentication](authentication.md)** - Securing sampling endpoints \ No newline at end of file diff --git a/docs/servers.md b/docs/servers.md deleted file mode 100644 index 605fdbddb..000000000 --- a/docs/servers.md +++ /dev/null @@ -1,353 +0,0 @@ -# Servers - -Learn how to create and manage MCP servers, including lifecycle management, configuration, and advanced patterns. - -## What is an MCP server? - -An MCP server exposes functionality to LLM applications through three core primitives: - -- **Resources** - Data that can be read by LLMs -- **Tools** - Functions that LLMs can call -- **Prompts** - Templates for LLM interactions - -The FastMCP framework provides a high-level, decorator-based way to build servers quickly. - -## Basic server creation - -### Minimal server - -```python -from mcp.server.fastmcp import FastMCP - -# Create a server -mcp = FastMCP("My Server") - -@mcp.tool() -def hello(name: str = "World") -> str: - """Say hello to someone.""" - return f"Hello, {name}!" - -if __name__ == "__main__": - mcp.run() -``` - -### Server with configuration - -```python -from mcp.server.fastmcp import FastMCP - -# Create server with custom configuration -mcp = FastMCP( - name="Analytics Server", - instructions="Provides data analytics and reporting tools" -) - -@mcp.tool() -def analyze_data(data: list[int]) -> dict[str, float]: - """Analyze a list of numbers.""" - return { - "mean": sum(data) / len(data), - "max": max(data), - "min": min(data), - "count": len(data) - } -``` - -## Server lifecycle management - -### Using lifespan for startup/shutdown - -For servers that need to initialize resources (databases, connections, etc.), use the lifespan pattern: - -```python -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from dataclasses import dataclass - -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - - -# Mock database for example -class Database: - @classmethod - async def connect(cls) -> "Database": - print("Connecting to database...") - return cls() - - async def disconnect(self) -> None: - print("Disconnecting from database...") - - def query(self, sql: str) -> dict: - return {"result": f"Query result for: {sql}"} - - -@dataclass -class AppContext: - """Application context with typed dependencies.""" - db: Database - - -@asynccontextmanager -async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: - """Manage application lifecycle with type-safe context.""" - # Startup: initialize resources - db = await Database.connect() - try: - yield AppContext(db=db) - finally: - # Shutdown: cleanup resources - await db.disconnect() - - -# Create server with lifespan -mcp = FastMCP("Database Server", lifespan=app_lifespan) - - -@mcp.tool() -def query_database(sql: str, ctx: Context[ServerSession, AppContext]) -> dict: - """Execute a database query.""" - # Access the database from lifespan context - db = ctx.request_context.lifespan_context.db - return db.query(sql) -``` - -### Benefits of lifespan management - -- **Resource initialization** - Set up databases, API clients, configuration -- **Graceful shutdown** - Clean up resources when server stops -- **Type safety** - Access initialized resources with full type hints -- **Shared state** - Resources available to all request handlers - -## Server configuration - -### Development vs production settings - -```python -from mcp.server.fastmcp import FastMCP - -# Development server with debug features -dev_mcp = FastMCP( - "Dev Server", - debug=True, - log_level="DEBUG" -) - -# Production server with optimized settings -prod_mcp = FastMCP( - "Production Server", - debug=False, - log_level="INFO", - stateless_http=True # Better for scaling -) -``` - -### Transport configuration - -```python -# Configure for different transports -mcp = FastMCP( - "Multi-Transport Server", - host="0.0.0.0", # Accept connections from any host - port=8000, - mount_path="/api/mcp", # Custom path for HTTP transport - sse_path="/events", # Custom SSE endpoint -) - -# Run with specific transport -if __name__ == "__main__": - mcp.run(transport="streamable-http") # or "stdio", "sse" -``` - -## Error handling and validation - -### Input validation - -```python -from typing import Annotated -from pydantic import Field, validator - -@mcp.tool() -def process_age( - age: Annotated[int, Field(ge=0, le=150, description="Person's age")] -) -> str: - """Process a person's age with validation.""" - if age < 18: - return "Minor" - elif age < 65: - return "Adult" - else: - return "Senior" -``` - -### Error handling patterns - -```python -@mcp.tool() -def divide_numbers(a: float, b: float) -> float: - """Divide two numbers with error handling.""" - if b == 0: - raise ValueError("Cannot divide by zero") - return a / b - -@mcp.tool() -async def fetch_data(url: str, ctx: Context) -> str: - """Fetch data with proper error handling.""" - try: - # Simulate network request - if not url.startswith("http"): - raise ValueError("URL must start with http or https") - - await ctx.info(f"Fetching data from {url}") - # ... actual implementation - return "Data fetched successfully" - - except ValueError as e: - await ctx.error(f"Invalid URL: {e}") - raise - except Exception as e: - await ctx.error(f"Failed to fetch data: {e}") - raise -``` - -## Server capabilities and metadata - -### Declaring capabilities - -```python -# Server automatically declares capabilities based on registered handlers -mcp = FastMCP("Feature Server") - -# Adding tools automatically enables the 'tools' capability -@mcp.tool() -def my_tool() -> str: - return "Tool result" - -# Adding resources automatically enables the 'resources' capability -@mcp.resource("data://{id}") -def get_data(id: str) -> str: - return f"Data for {id}" - -# Adding prompts automatically enables the 'prompts' capability -@mcp.prompt() -def my_prompt() -> str: - return "Prompt template" -``` - -### Server metadata access - -```python -@mcp.tool() -def server_info(ctx: Context) -> dict: - """Get information about the current server.""" - return { - "name": ctx.fastmcp.name, - "instructions": ctx.fastmcp.instructions, - "debug_mode": ctx.fastmcp.settings.debug, - "host": ctx.fastmcp.settings.host, - "port": ctx.fastmcp.settings.port, - } -``` - -## Testing servers - -### Unit testing individual components - -```python -import pytest -from mcp.server.fastmcp import FastMCP - -def test_server_creation(): - mcp = FastMCP("Test Server") - assert mcp.name == "Test Server" - -@pytest.mark.asyncio -async def test_tool_functionality(): - mcp = FastMCP("Test") - - @mcp.tool() - def add(a: int, b: int) -> int: - return a + b - - # Test the underlying function - result = add(2, 3) - assert result == 5 -``` - -### Integration testing with MCP Inspector - -```bash -# Start server in test mode -uv run mcp dev server.py --port 8001 - -# Test with curl -curl -X POST http://localhost:8001/mcp \ - -H "Content-Type: application/json" \ - -d '{"method": "tools/list", "params": {}}' -``` - -## Common patterns - -### Environment-based configuration - -```python -import os -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP( - "Configurable Server", - debug=os.getenv("DEBUG", "false").lower() == "true", - host=os.getenv("HOST", "localhost"), - port=int(os.getenv("PORT", "8000")) -) -``` - -### Multi-server applications - -```python -# Create specialized servers for different domains -auth_server = FastMCP("Auth Server") -data_server = FastMCP("Data Server") - -@auth_server.tool() -def login(username: str, password: str) -> str: - """Handle user authentication.""" - # ... auth logic - return "Login successful" - -@data_server.tool() -def get_user_data(user_id: str) -> dict: - """Retrieve user data.""" - # ... data retrieval logic - return {"user_id": user_id, "name": "John Doe"} -``` - -## Best practices - -### Server design - -- **Single responsibility** - Each server should have a focused purpose -- **Stateless when possible** - Avoid server-side state for better scalability -- **Clear naming** - Use descriptive server and tool names -- **Documentation** - Provide clear docstrings for all public interfaces - -### Performance considerations - -- **Use async/await** - For I/O-bound operations -- **Connection pooling** - Reuse database connections via lifespan -- **Caching** - Cache expensive computations where appropriate -- **Batch operations** - Group related operations when possible - -### Security - -- **Input validation** - Validate all tool parameters -- **Error handling** - Don't expose sensitive information in errors -- **Authentication** - Use OAuth 2.1 for protected resources -- **Rate limiting** - Implement rate limiting for expensive operations - -## Next steps - -- **[Learn about tools](tools.md)** - Create powerful LLM-callable functions -- **[Working with resources](resources.md)** - Expose data effectively -- **[Server deployment](running-servers.md)** - Run servers in production -- **[Authentication](authentication.md)** - Secure your servers \ No newline at end of file diff --git a/docs/streamable-http.md b/docs/streamable-http.md deleted file mode 100644 index 142dd219a..000000000 --- a/docs/streamable-http.md +++ /dev/null @@ -1,722 +0,0 @@ -# Streamable HTTP - -Streamable HTTP is the modern transport for MCP servers, designed for production deployments with better scalability, resumability, and flexibility than SSE transport. - -## Overview - -Streamable HTTP offers: - -- **Stateful and stateless modes** - Choose based on your scaling needs -- **Resumable connections** - Clients can reconnect and resume sessions -- **Event sourcing** - Built-in event store for reliability -- **JSON or SSE responses** - Flexible response formats -- **Better performance** - Optimized for high-throughput scenarios - -## Basic usage - -### Simple streamable HTTP server - -```python -from mcp.server.fastmcp import FastMCP - -# Create server -mcp = FastMCP(\"Streamable Server\") - -@mcp.tool() -def calculate(expression: str) -> float: - \"\"\"Safely evaluate mathematical expressions.\"\"\" - # Simple calculator (in production, use a proper math parser) - allowed = set('0123456789+-*/.() ') - if not all(c in allowed for c in expression): - raise ValueError(\"Invalid characters in expression\") - - try: - result = eval(expression) - return float(result) - except Exception as e: - raise ValueError(f\"Cannot evaluate expression: {e}\") - -# Run with streamable HTTP transport -if __name__ == \"__main__\": - mcp.run(transport=\"streamable-http\", host=\"0.0.0.0\", port=8000) -``` - -Access the server at `http://localhost:8000/mcp` - -## Configuration options - -### Stateful vs stateless - -```python -# Stateful server (default) - maintains session state -mcp_stateful = FastMCP(\"Stateful Server\") - -# Stateless server - no session persistence, better for scaling -mcp_stateless = FastMCP(\"Stateless Server\", stateless_http=True) - -# Stateless with JSON responses only (no SSE) -mcp_json = FastMCP(\"JSON Server\", stateless_http=True, json_response=True) -``` - -### Custom paths and ports - -```python -mcp = FastMCP( - \"Custom Server\", - host=\"0.0.0.0\", - port=3001, - mount_path=\"/api/mcp\", # Custom MCP endpoint - sse_path=\"/events\", # Custom SSE endpoint -) - -# Server available at: -# - http://localhost:3001/api/mcp (MCP endpoint) -# - http://localhost:3001/events (SSE endpoint) -``` - -## Client connections - -### HTTP client example - -```python -\"\"\" -Example HTTP client for streamable HTTP servers. -\"\"\" - -import asyncio -import aiohttp -import json - -async def call_mcp_tool(): - \"\"\"Call MCP tool via HTTP.\"\"\" - url = \"http://localhost:8000/mcp\" - - # Initialize connection - init_request = { - \"method\": \"initialize\", - \"params\": { - \"protocolVersion\": \"2025-06-18\", - \"clientInfo\": { - \"name\": \"HTTP Client\", - \"version\": \"1.0.0\" - }, - \"capabilities\": {} - } - } - - async with aiohttp.ClientSession() as session: - # Initialize - async with session.post(url, json=init_request) as response: - init_result = await response.json() - print(f\"Initialize: {init_result}\") - - # List tools - list_request = { - \"method\": \"tools/list\", - \"params\": {} - } - - async with session.post(url, json=list_request) as response: - tools_result = await response.json() - print(f\"Tools: {tools_result}\") - - # Call tool - call_request = { - \"method\": \"tools/call\", - \"params\": { - \"name\": \"calculate\", - \"arguments\": {\"expression\": \"2 + 3 * 4\"} - } - } - - async with session.post(url, json=call_request) as response: - call_result = await response.json() - print(f\"Result: {call_result}\") - -if __name__ == \"__main__\": - asyncio.run(call_mcp_tool()) -``` - -### Using the MCP client library - -```python -\"\"\" -Connect to streamable HTTP server using MCP client library. -\"\"\" - -import asyncio -from mcp import ClientSession -from mcp.client.streamable_http import streamablehttp_client - -async def connect_to_server(): - \"\"\"Connect using MCP client library.\"\"\" - - async with streamablehttp_client(\"http://localhost:8000/mcp\") as (read, write, _): - async with ClientSession(read, write) as session: - # Initialize connection - await session.initialize() - - # List available tools - tools = await session.list_tools() - print(f\"Available tools: {[tool.name for tool in tools.tools]}\") - - # Call a tool - result = await session.call_tool(\"calculate\", {\"expression\": \"10 / 2\"}) - content = result.content[0] - if hasattr(content, 'text'): - print(f\"Calculation result: {content.text}\") - -if __name__ == \"__main__\": - asyncio.run(connect_to_server()) -``` - -## Mounting to existing applications - -### Starlette integration - -```python -\"\"\" -Mount multiple MCP servers in a Starlette application. -\"\"\" - -import contextlib -from starlette.applications import Starlette -from starlette.routing import Mount, Route -from starlette.responses import JSONResponse -from mcp.server.fastmcp import FastMCP - -# Create specialized servers -auth_server = FastMCP(\"Auth Server\", stateless_http=True) -data_server = FastMCP(\"Data Server\", stateless_http=True) - -@auth_server.tool() -def login(username: str, password: str) -> dict: - \"\"\"Authenticate user.\"\"\" - # Simple auth (use proper authentication in production) - if username == \"admin\" and password == \"secret\": - return {\"token\": \"auth-token-123\", \"expires\": 3600} - raise ValueError(\"Invalid credentials\") - -@data_server.tool() -def get_data(query: str) -> list[dict]: - \"\"\"Retrieve data based on query.\"\"\" - # Mock data - return [{\"id\": 1, \"data\": f\"Result for {query}\"}] - -# Health check endpoint -async def health_check(request): - return JSONResponse({\"status\": \"healthy\"}) - -# Combined lifespan manager -@contextlib.asynccontextmanager -async def lifespan(app): - async with contextlib.AsyncExitStack() as stack: - await stack.enter_async_context(auth_server.session_manager.run()) - await stack.enter_async_context(data_server.session_manager.run()) - yield - -# Create Starlette app -app = Starlette( - routes=[ - Route(\"/health\", health_check), - Mount(\"/auth\", auth_server.streamable_http_app()), - Mount(\"/data\", data_server.streamable_http_app()), - ], - lifespan=lifespan -) - -# Run with: uvicorn app:app --host 0.0.0.0 --port 8000 -``` - -### FastAPI integration - -```python -\"\"\" -Integrate MCP server with FastAPI. -\"\"\" - -from fastapi import FastAPI -from mcp.server.fastmcp import FastMCP - -# Create FastAPI app -app = FastAPI(title=\"API with MCP\") - -# Create MCP server -mcp = FastMCP(\"FastAPI MCP\", stateless_http=True) - -@mcp.tool() -def process_request(data: str) -> dict: - \"\"\"Process API request data.\"\"\" - return {\"processed\": data, \"length\": len(data)} - -# Regular FastAPI endpoint -@app.get(\"/\") -async def root(): - return {\"message\": \"FastAPI with MCP integration\"} - -# Mount MCP server -app.mount(\"/mcp\", mcp.streamable_http_app()) - -# Startup event -@app.on_event(\"startup\") -async def startup(): - await mcp.session_manager.start() - -# Shutdown event -@app.on_event(\"shutdown\") -async def shutdown(): - await mcp.session_manager.stop() -``` - -## Advanced configuration - -### Event store configuration - -```python -\"\"\" -Server with custom event store configuration. -\"\"\" - -from mcp.server.fastmcp import FastMCP -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -import aioredis - -@asynccontextmanager -async def redis_lifespan(server: FastMCP) -> AsyncIterator[dict]: - \"\"\"Configure Redis for event storage.\"\"\" - redis = aioredis.from_url(\"redis://localhost:6379\") - try: - yield {\"redis\": redis} - finally: - await redis.close() - -# Create server with event store -mcp = FastMCP( - \"Event Store Server\", - lifespan=redis_lifespan, - stateless_http=False # Stateful for event sourcing -) - -@mcp.tool() -async def store_event(event_type: str, data: dict, ctx) -> str: - \"\"\"Store an event with Redis backend.\"\"\" - import json - import time - - redis = ctx.request_context.lifespan_context[\"redis\"] - - event = { - \"type\": event_type, - \"data\": data, - \"timestamp\": time.time(), - \"id\": f\"event_{hash(str(data)) % 10000:04d}\" - } - - # Store event in Redis - await redis.lpush(\"events\", json.dumps(event)) - await redis.ltrim(\"events\", 0, 999) # Keep last 1000 events - - return event[\"id\"] -``` - -### Custom middleware - -```python -\"\"\" -Server with custom middleware for logging and authentication. -\"\"\" - -from starlette.middleware.base import BaseHTTPMiddleware -from starlette.requests import Request -from starlette.responses import Response -import time -import logging - -logger = logging.getLogger(\"mcp.middleware\") - -class LoggingMiddleware(BaseHTTPMiddleware): - \"\"\"Middleware to log all requests.\"\"\" - - async def dispatch(self, request: Request, call_next): - start_time = time.time() - - # Log request - logger.info(f\"Request: {request.method} {request.url.path}\") - - response = await call_next(request) - - # Log response - duration = time.time() - start_time - logger.info(f\"Response: {response.status_code} ({duration:.3f}s)\") - - return response - -class AuthMiddleware(BaseHTTPMiddleware): - \"\"\"Simple API key authentication middleware.\"\"\" - - async def dispatch(self, request: Request, call_next): - # Skip auth for health checks - if request.url.path == \"/health\": - return await call_next(request) - - # Check API key - api_key = request.headers.get(\"X-API-Key\") - if not api_key or api_key != \"secret-key-123\": - return Response(\"Unauthorized\", status_code=401) - - return await call_next(request) - -# Create server with middleware -mcp = FastMCP(\"Middleware Server\") - -@mcp.tool() -def protected_operation() -> str: - \"\"\"Operation that requires authentication.\"\"\" - return \"This operation is protected by middleware\" - -# Add middleware to the ASGI app -app = mcp.streamable_http_app() -app.add_middleware(LoggingMiddleware) -app.add_middleware(AuthMiddleware) - -if __name__ == \"__main__\": - # Custom ASGI server setup - import uvicorn - uvicorn.run(app, host=\"0.0.0.0\", port=8000) -``` - -## Performance optimization - -### Connection pooling - -```python -\"\"\" -High-performance server with connection pooling. -\"\"\" - -import asyncpg -import aioredis -from contextlib import asynccontextmanager -from collections.abc import AsyncIterator -from dataclasses import dataclass - -@dataclass -class PerformanceContext: - db_pool: asyncpg.Pool - redis_pool: aioredis.ConnectionPool - -@asynccontextmanager -async def performance_lifespan(server: FastMCP) -> AsyncIterator[PerformanceContext]: - \"\"\"High-performance lifespan with connection pools.\"\"\" - - # Database connection pool - db_pool = await asyncpg.create_pool( - \"postgresql://user:pass@localhost/db\", - min_size=10, - max_size=50, - max_queries=50000, - max_inactive_connection_lifetime=300, - ) - - # Redis connection pool - redis_pool = aioredis.ConnectionPool.from_url( - \"redis://localhost:6379\", - max_connections=20 - ) - - try: - yield PerformanceContext(db_pool=db_pool, redis_pool=redis_pool) - finally: - await db_pool.close() - redis_pool.disconnect() - -# Optimized server configuration -mcp = FastMCP( - \"High Performance Server\", - lifespan=performance_lifespan, - stateless_http=True, # Better for horizontal scaling - json_response=True, # Disable SSE for pure HTTP - host=\"0.0.0.0\", - port=8000 -) - -@mcp.tool() -async def fast_query(sql: str, ctx) -> list[dict]: - \"\"\"Execute database query using connection pool.\"\"\" - context = ctx.request_context.lifespan_context - - async with context.db_pool.acquire() as conn: - rows = await conn.fetch(sql) - return [dict(row) for row in rows] - -@mcp.tool() -async def cache_operation(key: str, value: str, ctx) -> str: - \"\"\"Cache operation using Redis pool.\"\"\" - context = ctx.request_context.lifespan_context - - redis = aioredis.Redis(connection_pool=context.redis_pool) - await redis.set(key, value, ex=3600) # 1 hour expiration - - return f\"Cached {key} = {value}\" -``` - -### Load balancing setup - -```yaml -# docker-compose.yml for load-balanced setup -version: '3.8' - -services: - nginx: - image: nginx:alpine - ports: - - \"80:80\" - volumes: - - ./nginx.conf:/etc/nginx/nginx.conf - depends_on: - - mcp-server-1 - - mcp-server-2 - - mcp-server-1: - build: . - environment: - - INSTANCE_ID=server-1 - - PORT=8000 - - mcp-server-2: - build: . - environment: - - INSTANCE_ID=server-2 - - PORT=8000 - - redis: - image: redis:alpine - - postgres: - image: postgres:15 - environment: - POSTGRES_DB: mcpdb - POSTGRES_USER: mcpuser - POSTGRES_PASSWORD: mcppass -``` - -Nginx configuration for load balancing: - -```nginx -# nginx.conf -events { - worker_connections 1024; -} - -http { - upstream mcp_servers { - server mcp-server-1:8000; - server mcp-server-2:8000; - } - - server { - listen 80; - - location /mcp { - proxy_pass http://mcp_servers; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection \"upgrade\"; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - } - } -} -``` - -## Monitoring and debugging - -### Health checks and metrics - -```python -@mcp.tool() -async def server_metrics() -> dict: - \"\"\"Get server performance metrics.\"\"\" - import psutil - import time - - process = psutil.Process() - - return { - \"memory\": { - \"rss\": process.memory_info().rss, - \"vms\": process.memory_info().vms, - \"percent\": process.memory_percent() - }, - \"cpu\": { - \"percent\": process.cpu_percent(), - \"times\": process.cpu_times()._asdict() - }, - \"connections\": len(process.connections()), - \"uptime\": time.time() - process.create_time(), - \"threads\": process.num_threads() - } - -@mcp.tool() -async def connection_info(ctx) -> dict: - \"\"\"Get information about current connection.\"\"\" - return { - \"request_id\": ctx.request_id, - \"client_id\": ctx.client_id, - \"server_name\": ctx.fastmcp.name, - \"transport\": \"streamable-http\", - \"stateless\": ctx.fastmcp.settings.stateless_http - } -``` - -### Request tracing - -```python -import uuid -from starlette.middleware.base import BaseHTTPMiddleware - -class TracingMiddleware(BaseHTTPMiddleware): - \"\"\"Add tracing to all requests.\"\"\" - - async def dispatch(self, request: Request, call_next): - # Generate trace ID - trace_id = str(uuid.uuid4()) - request.state.trace_id = trace_id - - # Add to response headers - response = await call_next(request) - response.headers[\"X-Trace-ID\"] = trace_id - - return response - -@mcp.tool() -async def traced_operation(data: str, ctx) -> dict: - \"\"\"Operation with distributed tracing.\"\"\" - # In a real implementation, you'd get trace_id from request context - trace_id = f\"trace_{hash(data) % 10000:04d}\" - - await ctx.info(f\"[{trace_id}] Processing operation\") - - result = {\"processed\": data, \"trace_id\": trace_id} - - await ctx.info(f\"[{trace_id}] Operation completed\") - - return result -``` - -## Testing streamable HTTP servers - -### Integration testing - -```python -\"\"\" -Integration tests for streamable HTTP server. -\"\"\" - -import pytest -import asyncio -import aiohttp -from mcp.server.fastmcp import FastMCP - -@pytest.fixture -async def test_server(): - \"\"\"Create test server.\"\"\" - mcp = FastMCP(\"Test Server\") - - @mcp.tool() - def test_tool(value: str) -> str: - return f\"Test: {value}\" - - # Start server in background - server_task = asyncio.create_task( - mcp.run_async(transport=\"streamable-http\", port=8999) - ) - - # Wait for server to start - await asyncio.sleep(0.1) - - yield \"http://localhost:8999/mcp\" - - # Cleanup - server_task.cancel() - try: - await server_task - except asyncio.CancelledError: - pass - -@pytest.mark.asyncio -async def test_server_connection(test_server): - \"\"\"Test basic server connectivity.\"\"\" - url = test_server - - async with aiohttp.ClientSession() as session: - # Test initialization - init_request = { - \"method\": \"initialize\", - \"params\": { - \"protocolVersion\": \"2025-06-18\", - \"clientInfo\": {\"name\": \"Test Client\", \"version\": \"1.0.0\"}, - \"capabilities\": {} - } - } - - async with session.post(url, json=init_request) as response: - assert response.status == 200 - result = await response.json() - assert \"result\" in result - -@pytest.mark.asyncio -async def test_tool_call(test_server): - \"\"\"Test tool invocation.\"\"\" - url = test_server - - async with aiohttp.ClientSession() as session: - # Call tool - call_request = { - \"method\": \"tools/call\", - \"params\": { - \"name\": \"test_tool\", - \"arguments\": {\"value\": \"hello\"} - } - } - - async with session.post(url, json=call_request) as response: - assert response.status == 200 - result = await response.json() - assert \"result\" in result -``` - -## Best practices - -### Deployment guidelines - -- **Use stateless mode** for horizontal scaling -- **Enable connection pooling** for database and cache operations -- **Implement health checks** for load balancer integration -- **Add proper logging** with structured output and trace IDs -- **Use reverse proxy** (nginx/Apache) for SSL termination and load balancing - -### Performance tips - -- **Choose stateless mode** for better scalability -- **Use connection pools** for external services -- **Implement caching** for expensive operations -- **Monitor resource usage** with metrics endpoints -- **Optimize database queries** and use proper indexing - -### Security considerations - -- **Use HTTPS** in production -- **Implement authentication** middleware -- **Validate inputs** thoroughly -- **Rate limit** requests to prevent abuse -- **Log security events** for monitoring - -## Next steps - -- **[ASGI integration](asgi-integration.md)** - Integrate with web frameworks -- **[Running servers](running-servers.md)** - Production deployment strategies -- **[Authentication](authentication.md)** - Secure your HTTP endpoints -- **[Client development](writing-clients.md)** - Build HTTP clients \ No newline at end of file diff --git a/docs/structured-output.md b/docs/structured-output.md deleted file mode 100644 index 47214beed..000000000 --- a/docs/structured-output.md +++ /dev/null @@ -1,1477 +0,0 @@ -# Structured output - -Learn how to create structured, typed outputs from your MCP tools using Pydantic models, TypedDict, and other approaches for better data exchange. - -## Overview - -Structured output provides: - -- **Type safety** - Ensure outputs match expected schemas -- **Data validation** - Automatic validation of output data -- **Documentation** - Self-documenting APIs with clear schemas -- **Client compatibility** - Easier parsing and processing for clients -- **Error prevention** - Catch output errors before they reach clients - -## Pydantic models - -### Basic structured models - -```python -""" -Structured output using Pydantic models. -""" - -from pydantic import BaseModel, Field, validator -from typing import List, Optional, Dict, Any -from datetime import datetime -from enum import Enum -from mcp.server.fastmcp import FastMCP - -# Create server -mcp = FastMCP("Structured Output Server") - -# Define output models -class TaskStatus(str, Enum): - """Task status enumeration.""" - PENDING = "pending" - IN_PROGRESS = "in_progress" - COMPLETED = "completed" - FAILED = "failed" - -class Task(BaseModel): - """Task model with structured output.""" - id: str = Field(..., description="Unique task identifier") - title: str = Field(..., min_length=1, max_length=200, description="Task title") - description: Optional[str] = Field(None, description="Task description") - status: TaskStatus = Field(default=TaskStatus.PENDING, description="Current task status") - priority: int = Field(default=1, ge=1, le=5, description="Task priority (1-5)") - created_at: datetime = Field(default_factory=datetime.now, description="Creation timestamp") - updated_at: Optional[datetime] = Field(None, description="Last update timestamp") - tags: List[str] = Field(default_factory=list, description="Task tags") - metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata") - - @validator('tags') - def validate_tags(cls, v): - """Validate tags are not empty strings.""" - return [tag.strip() for tag in v if tag.strip()] - - @validator('updated_at', always=True) - def set_updated_at(cls, v, values): - """Set updated_at when status changes.""" - return v or datetime.now() - -class TaskList(BaseModel): - """List of tasks with metadata.""" - tasks: List[Task] = Field(..., description="List of tasks") - total_count: int = Field(..., description="Total number of tasks") - page: int = Field(default=1, ge=1, description="Current page number") - page_size: int = Field(default=10, ge=1, le=100, description="Number of tasks per page") - has_more: bool = Field(..., description="Whether there are more tasks") - -class TaskStatistics(BaseModel): - """Task statistics model.""" - total_tasks: int = Field(..., ge=0, description="Total number of tasks") - by_status: Dict[TaskStatus, int] = Field(..., description="Task count by status") - by_priority: Dict[int, int] = Field(..., description="Task count by priority") - average_completion_time: Optional[float] = Field(None, description="Average completion time in hours") - - @validator('by_status', 'by_priority') - def ensure_non_negative_counts(cls, v): - """Ensure all counts are non-negative.""" - return {k: max(0, count) for k, count in v.items()} - -# Tool implementations with structured output -@mcp.tool() -def create_task( - title: str, - description: str = "", - priority: int = 1, - tags: List[str] = None -) -> Task: - """Create a new task with structured output.""" - import uuid - - task = Task( - id=str(uuid.uuid4()), - title=title, - description=description, - priority=priority, - tags=tags or [] - ) - - return task - -@mcp.tool() -def list_tasks( - page: int = 1, - page_size: int = 10, - status_filter: Optional[TaskStatus] = None -) -> TaskList: - """List tasks with pagination and filtering.""" - import uuid - - # Mock task data - all_tasks = [] - for i in range(25): # Mock 25 tasks - task = Task( - id=str(uuid.uuid4()), - title=f"Task {i + 1}", - description=f"Description for task {i + 1}", - status=list(TaskStatus)[i % 4], - priority=(i % 5) + 1, - tags=[f"tag-{i % 3}", f"category-{i % 2}"] - ) - all_tasks.append(task) - - # Apply status filter - if status_filter: - filtered_tasks = [t for t in all_tasks if t.status == status_filter] - else: - filtered_tasks = all_tasks - - # Apply pagination - start_idx = (page - 1) * page_size - end_idx = start_idx + page_size - page_tasks = filtered_tasks[start_idx:end_idx] - - return TaskList( - tasks=page_tasks, - total_count=len(filtered_tasks), - page=page, - page_size=page_size, - has_more=end_idx < len(filtered_tasks) - ) - -@mcp.tool() -def get_task_statistics() -> TaskStatistics: - """Get task statistics with structured output.""" - # Mock statistics calculation - total_tasks = 25 - - by_status = { - TaskStatus.PENDING: 8, - TaskStatus.IN_PROGRESS: 5, - TaskStatus.COMPLETED: 10, - TaskStatus.FAILED: 2 - } - - by_priority = {1: 5, 2: 6, 3: 7, 4: 4, 5: 3} - - return TaskStatistics( - total_tasks=total_tasks, - by_status=by_status, - by_priority=by_priority, - average_completion_time=24.5 - ) - -if __name__ == "__main__": - mcp.run() -``` - -### Nested structured models - -```python -""" -Complex nested structured models. -""" - -from pydantic import BaseModel, Field, validator -from typing import List, Optional, Dict, Any, Union -from datetime import datetime, date -from decimal import Decimal - -class Address(BaseModel): - """Address model.""" - street: str = Field(..., description="Street address") - city: str = Field(..., description="City name") - state: str = Field(..., description="State or province") - postal_code: str = Field(..., description="Postal/ZIP code") - country: str = Field(default="US", description="Country code") - - @validator('postal_code') - def validate_postal_code(cls, v, values): - """Validate postal code format based on country.""" - country = values.get('country', 'US') - if country == 'US': - import re - if not re.match(r'^\\d{5}(-\\d{4})?$', v): - raise ValueError('Invalid US postal code format') - return v - -class ContactInfo(BaseModel): - """Contact information model.""" - email: Optional[str] = Field(None, description="Email address") - phone: Optional[str] = Field(None, description="Phone number") - website: Optional[str] = Field(None, description="Website URL") - - @validator('email') - def validate_email(cls, v): - """Validate email format.""" - if v: - import re - if not re.match(r'^[^@]+@[^@]+\\.[^@]+$', v): - raise ValueError('Invalid email format') - return v - -class Customer(BaseModel): - """Customer model with nested structures.""" - id: str = Field(..., description="Customer ID") - name: str = Field(..., description="Customer name") - email: str = Field(..., description="Primary email") - addresses: List[Address] = Field(default_factory=list, description="Customer addresses") - contact_info: Optional[ContactInfo] = Field(None, description="Additional contact info") - created_at: datetime = Field(default_factory=datetime.now, description="Account creation time") - is_active: bool = Field(default=True, description="Account status") - metadata: Dict[str, Any] = Field(default_factory=dict, description="Custom metadata") - -class OrderItem(BaseModel): - """Order item model.""" - product_id: str = Field(..., description="Product identifier") - product_name: str = Field(..., description="Product name") - quantity: int = Field(..., ge=1, description="Item quantity") - unit_price: Decimal = Field(..., ge=0, description="Price per unit") - discount_percent: float = Field(default=0.0, ge=0, le=100, description="Discount percentage") - - @property - def subtotal(self) -> Decimal: - """Calculate item subtotal.""" - return self.unit_price * self.quantity - - @property - def discount_amount(self) -> Decimal: - """Calculate discount amount.""" - return self.subtotal * Decimal(self.discount_percent / 100) - - @property - def total(self) -> Decimal: - """Calculate item total after discount.""" - return self.subtotal - self.discount_amount - -class Order(BaseModel): - """Order model with complex calculations.""" - id: str = Field(..., description="Order ID") - customer: Customer = Field(..., description="Customer information") - items: List[OrderItem] = Field(..., min_items=1, description="Order items") - order_date: date = Field(default_factory=date.today, description="Order date") - shipping_address: Address = Field(..., description="Shipping address") - billing_address: Optional[Address] = Field(None, description="Billing address") - notes: Optional[str] = Field(None, description="Order notes") - - @validator('billing_address', always=True) - def set_billing_address(cls, v, values): - """Use shipping address as billing if not provided.""" - return v or values.get('shipping_address') - - @property - def subtotal(self) -> Decimal: - """Calculate order subtotal.""" - return sum(item.subtotal for item in self.items) - - @property - def total_discount(self) -> Decimal: - """Calculate total discount amount.""" - return sum(item.discount_amount for item in self.items) - - @property - def total(self) -> Decimal: - """Calculate order total.""" - return sum(item.total for item in self.items) - -class OrderSummary(BaseModel): - """Order summary with aggregated data.""" - order_count: int = Field(..., description="Total number of orders") - total_revenue: Decimal = Field(..., description="Total revenue") - average_order_value: Decimal = Field(..., description="Average order value") - top_customers: List[Customer] = Field(..., description="Top customers by order value") - recent_orders: List[Order] = Field(..., description="Most recent orders") - -# Tools using nested models -@mcp.tool() -def create_customer( - name: str, - email: str, - street: str, - city: str, - state: str, - postal_code: str, - country: str = "US" -) -> Customer: - """Create a new customer with address.""" - import uuid - - address = Address( - street=street, - city=city, - state=state, - postal_code=postal_code, - country=country - ) - - customer = Customer( - id=str(uuid.uuid4()), - name=name, - email=email, - addresses=[address] - ) - - return customer - -@mcp.tool() -def create_order( - customer_data: Dict[str, Any], - items_data: List[Dict[str, Any]], - shipping_address_data: Dict[str, Any], - notes: str = "" -) -> Order: - """Create a new order with complex nested data.""" - import uuid - from decimal import Decimal - - # Parse customer data - customer = Customer(**customer_data) - - # Parse shipping address - shipping_address = Address(**shipping_address_data) - - # Parse order items - items = [] - for item_data in items_data: - # Convert price to Decimal - item_data['unit_price'] = Decimal(str(item_data['unit_price'])) - items.append(OrderItem(**item_data)) - - order = Order( - id=str(uuid.uuid4()), - customer=customer, - items=items, - shipping_address=shipping_address, - notes=notes - ) - - return order - -@mcp.tool() -def get_order_summary(days: int = 30) -> OrderSummary: - """Get order summary for the specified number of days.""" - from decimal import Decimal - import uuid - - # Mock data generation - mock_customers = [] - mock_orders = [] - - for i in range(5): - customer = Customer( - id=str(uuid.uuid4()), - name=f"Customer {i+1}", - email=f"customer{i+1}@example.com" - ) - mock_customers.append(customer) - - # Create mock order for this customer - order_items = [ - OrderItem( - product_id=str(uuid.uuid4()), - product_name=f"Product {j+1}", - quantity=j+1, - unit_price=Decimal("19.99"), - discount_percent=5.0 if j > 0 else 0.0 - ) - for j in range(2) - ] - - shipping_address = Address( - street=f"{100 + i} Main St", - city="Example City", - state="CA", - postal_code="90210", - country="US" - ) - - order = Order( - id=str(uuid.uuid4()), - customer=customer, - items=order_items, - shipping_address=shipping_address - ) - mock_orders.append(order) - - total_revenue = sum(order.total for order in mock_orders) - average_order_value = total_revenue / len(mock_orders) - - return OrderSummary( - order_count=len(mock_orders), - total_revenue=total_revenue, - average_order_value=average_order_value, - top_customers=mock_customers[:3], - recent_orders=mock_orders[:3] - ) - -if __name__ == "__main__": - mcp.run() -``` - -## TypedDict approach - -### Using TypedDict for structured output - -```python -""" -Structured output using TypedDict for Python 3.8+ compatibility. -""" - -from typing import TypedDict, List, Optional, Dict, Any, Union, Literal -from typing_extensions import NotRequired -from datetime import datetime -import json - -# Define TypedDict schemas -class UserProfile(TypedDict): - """User profile structure.""" - id: str - username: str - email: str - full_name: str - is_active: bool - created_at: str # ISO format datetime - updated_at: NotRequired[str] # Optional field - preferences: Dict[str, Any] - -class PostStats(TypedDict): - """Post statistics structure.""" - views: int - likes: int - comments: int - shares: int - engagement_rate: float - -class Post(TypedDict): - """Blog post structure.""" - id: str - title: str - content: str - author: UserProfile - status: Literal["draft", "published", "archived"] - tags: List[str] - stats: PostStats - created_at: str - published_at: NotRequired[str] - -class PostListResponse(TypedDict): - """Post list response structure.""" - posts: List[Post] - pagination: Dict[str, Union[int, bool]] - filters_applied: Dict[str, Any] - -class AnalyticsData(TypedDict): - """Analytics data structure.""" - period: str - total_posts: int - total_views: int - total_engagement: int - top_posts: List[Post] - user_stats: Dict[str, Any] - -# Validation functions for TypedDict -def validate_user_profile(data: Dict[str, Any]) -> UserProfile: - """Validate and create UserProfile.""" - required_fields = ['id', 'username', 'email', 'full_name', 'is_active', 'created_at', 'preferences'] - - for field in required_fields: - if field not in data: - raise ValueError(f"Missing required field: {field}") - - # Type validations - if not isinstance(data['is_active'], bool): - raise ValueError("is_active must be boolean") - - if not isinstance(data['preferences'], dict): - raise ValueError("preferences must be a dictionary") - - # Validate email format - import re - if not re.match(r'^[^@]+@[^@]+\\.[^@]+$', data['email']): - raise ValueError("Invalid email format") - - return UserProfile( - id=str(data['id']), - username=str(data['username']), - email=str(data['email']), - full_name=str(data['full_name']), - is_active=bool(data['is_active']), - created_at=str(data['created_at']), - preferences=dict(data['preferences']), - **{k: v for k, v in data.items() if k in ['updated_at'] and v is not None} - ) - -def validate_post_stats(data: Dict[str, Any]) -> PostStats: - """Validate and create PostStats.""" - required_fields = ['views', 'likes', 'comments', 'shares', 'engagement_rate'] - - for field in required_fields: - if field not in data: - raise ValueError(f"Missing required field: {field}") - - # Ensure non-negative values - for field in ['views', 'likes', 'comments', 'shares']: - if not isinstance(data[field], int) or data[field] < 0: - raise ValueError(f"{field} must be a non-negative integer") - - if not isinstance(data['engagement_rate'], (int, float)) or data['engagement_rate'] < 0: - raise ValueError("engagement_rate must be a non-negative number") - - return PostStats( - views=int(data['views']), - likes=int(data['likes']), - comments=int(data['comments']), - shares=int(data['shares']), - engagement_rate=float(data['engagement_rate']) - ) - -def validate_post(data: Dict[str, Any]) -> Post: - """Validate and create Post.""" - required_fields = ['id', 'title', 'content', 'author', 'status', 'tags', 'stats', 'created_at'] - - for field in required_fields: - if field not in data: - raise ValueError(f"Missing required field: {field}") - - # Validate status - valid_statuses = ['draft', 'published', 'archived'] - if data['status'] not in valid_statuses: - raise ValueError(f"status must be one of: {valid_statuses}") - - # Validate tags - if not isinstance(data['tags'], list): - raise ValueError("tags must be a list") - - # Validate nested structures - author = validate_user_profile(data['author']) - stats = validate_post_stats(data['stats']) - - result = Post( - id=str(data['id']), - title=str(data['title']), - content=str(data['content']), - author=author, - status=data['status'], # Already validated - tags=[str(tag) for tag in data['tags']], - stats=stats, - created_at=str(data['created_at']) - ) - - # Add optional fields - if 'published_at' in data and data['published_at'] is not None: - result['published_at'] = str(data['published_at']) - - return result - -# MCP tools using TypedDict -@mcp.tool() -def create_user( - username: str, - email: str, - full_name: str, - preferences: Dict[str, Any] = None -) -> UserProfile: - """Create a new user with structured output.""" - import uuid - from datetime import datetime - - user_data = { - 'id': str(uuid.uuid4()), - 'username': username, - 'email': email, - 'full_name': full_name, - 'is_active': True, - 'created_at': datetime.now().isoformat(), - 'preferences': preferences or {} - } - - return validate_user_profile(user_data) - -@mcp.tool() -def create_post( - title: str, - content: str, - author_id: str, - tags: List[str] = None, - status: str = "draft" -) -> Post: - """Create a new blog post.""" - import uuid - from datetime import datetime - - # Mock author data - author_data = { - 'id': author_id, - 'username': f'user_{author_id[:8]}', - 'email': f'user_{author_id[:8]}@example.com', - 'full_name': 'Example User', - 'is_active': True, - 'created_at': datetime.now().isoformat(), - 'preferences': {'theme': 'light', 'notifications': True} - } - - stats_data = { - 'views': 0, - 'likes': 0, - 'comments': 0, - 'shares': 0, - 'engagement_rate': 0.0 - } - - post_data = { - 'id': str(uuid.uuid4()), - 'title': title, - 'content': content, - 'author': author_data, - 'status': status, - 'tags': tags or [], - 'stats': stats_data, - 'created_at': datetime.now().isoformat() - } - - if status == 'published': - post_data['published_at'] = datetime.now().isoformat() - - return validate_post(post_data) - -@mcp.tool() -def list_posts( - status: str = "published", - page: int = 1, - page_size: int = 10, - tag_filter: str = None -) -> PostListResponse: - """List posts with filtering and pagination.""" - import uuid - from datetime import datetime - - # Generate mock posts - posts = [] - for i in range(page_size): - author_data = { - 'id': str(uuid.uuid4()), - 'username': f'author_{i}', - 'email': f'author_{i}@example.com', - 'full_name': f'Author {i}', - 'is_active': True, - 'created_at': datetime.now().isoformat(), - 'preferences': {} - } - - stats_data = { - 'views': (i + 1) * 100, - 'likes': (i + 1) * 10, - 'comments': (i + 1) * 2, - 'shares': i + 1, - 'engagement_rate': min(50.0, (i + 1) * 2.5) - } - - post_tags = [f'tag-{i % 3}', f'category-{i % 2}'] - if tag_filter: - post_tags.append(tag_filter) - - post_data = { - 'id': str(uuid.uuid4()), - 'title': f'Post {i + 1}', - 'content': f'Content for post {i + 1}...', - 'author': author_data, - 'status': status, - 'tags': post_tags, - 'stats': stats_data, - 'created_at': datetime.now().isoformat() - } - - if status == 'published': - post_data['published_at'] = datetime.now().isoformat() - - posts.append(validate_post(post_data)) - - return PostListResponse( - posts=posts, - pagination={ - 'page': page, - 'page_size': page_size, - 'total_pages': 5, # Mock total pages - 'has_next': page < 5, - 'has_prev': page > 1 - }, - filters_applied={ - 'status': status, - 'tag_filter': tag_filter - } - ) - -@mcp.tool() -def get_analytics(period: str = "month") -> AnalyticsData: - """Get analytics data for the specified period.""" - # Generate mock analytics - total_posts = 50 - total_views = 10000 - total_engagement = 2500 - - # Create mock top posts - top_posts = [] - for i in range(3): - author_data = { - 'id': str(uuid.uuid4()), - 'username': f'top_author_{i}', - 'email': f'top_author_{i}@example.com', - 'full_name': f'Top Author {i}', - 'is_active': True, - 'created_at': datetime.now().isoformat(), - 'preferences': {} - } - - stats_data = { - 'views': 1000 - (i * 100), - 'likes': 100 - (i * 10), - 'comments': 50 - (i * 5), - 'shares': 20 - (i * 2), - 'engagement_rate': 15.0 - (i * 2.0) - } - - post_data = { - 'id': str(uuid.uuid4()), - 'title': f'Top Post {i + 1}', - 'content': f'Content for top post {i + 1}...', - 'author': author_data, - 'status': 'published', - 'tags': ['trending', f'category-{i}'], - 'stats': stats_data, - 'created_at': datetime.now().isoformat(), - 'published_at': datetime.now().isoformat() - } - - top_posts.append(validate_post(post_data)) - - return AnalyticsData( - period=period, - total_posts=total_posts, - total_views=total_views, - total_engagement=total_engagement, - top_posts=top_posts, - user_stats={ - 'total_users': 500, - 'active_users': 350, - 'new_users_this_period': 25 - } - ) - -if __name__ == "__main__": - mcp.run() -``` - -## JSON Schema validation - -### Schema-based validation - -```python -""" -JSON Schema-based structured output validation. -""" - -import json -import jsonschema -from typing import Any, Dict, List -from jsonschema import validate, ValidationError - -# Define JSON schemas -USER_SCHEMA = { - "type": "object", - "properties": { - "id": {"type": "string", "pattern": "^[a-f0-9-]{36}$"}, - "name": {"type": "string", "minLength": 1, "maxLength": 100}, - "email": {"type": "string", "format": "email"}, - "age": {"type": "integer", "minimum": 0, "maximum": 150}, - "preferences": { - "type": "object", - "properties": { - "theme": {"type": "string", "enum": ["light", "dark"]}, - "notifications": {"type": "boolean"}, - "language": {"type": "string", "pattern": "^[a-z]{2}$"} - }, - "additionalProperties": False - }, - "roles": { - "type": "array", - "items": {"type": "string", "enum": ["user", "admin", "moderator"]}, - "uniqueItems": True - } - }, - "required": ["id", "name", "email", "age"], - "additionalProperties": False -} - -PRODUCT_SCHEMA = { - "type": "object", - "properties": { - "id": {"type": "string"}, - "name": {"type": "string", "minLength": 1}, - "description": {"type": "string"}, - "price": {"type": "number", "minimum": 0}, - "currency": {"type": "string", "pattern": "^[A-Z]{3}$"}, - "category": { - "type": "object", - "properties": { - "id": {"type": "string"}, - "name": {"type": "string"}, - "parent_id": {"type": ["string", "null"]} - }, - "required": ["id", "name"] - }, - "tags": { - "type": "array", - "items": {"type": "string"}, - "maxItems": 10 - }, - "in_stock": {"type": "boolean"}, - "stock_quantity": {"type": "integer", "minimum": 0}, - "created_at": {"type": "string", "format": "date-time"}, - "updated_at": {"type": "string", "format": "date-time"} - }, - "required": ["id", "name", "price", "currency", "category", "in_stock"], - "additionalProperties": False -} - -ORDER_SCHEMA = { - "type": "object", - "properties": { - "id": {"type": "string"}, - "customer": {"$ref": "#/definitions/user"}, - "items": { - "type": "array", - "items": { - "type": "object", - "properties": { - "product": {"$ref": "#/definitions/product"}, - "quantity": {"type": "integer", "minimum": 1}, - "unit_price": {"type": "number", "minimum": 0}, - "discount": {"type": "number", "minimum": 0, "maximum": 1} - }, - "required": ["product", "quantity", "unit_price"] - }, - "minItems": 1 - }, - "status": {"type": "string", "enum": ["pending", "processing", "shipped", "delivered", "cancelled"]}, - "total_amount": {"type": "number", "minimum": 0}, - "currency": {"type": "string", "pattern": "^[A-Z]{3}$"}, - "created_at": {"type": "string", "format": "date-time"}, - "shipping_address": { - "type": "object", - "properties": { - "street": {"type": "string"}, - "city": {"type": "string"}, - "state": {"type": "string"}, - "postal_code": {"type": "string"}, - "country": {"type": "string", "pattern": "^[A-Z]{2}$"} - }, - "required": ["street", "city", "state", "postal_code", "country"] - } - }, - "required": ["id", "customer", "items", "status", "total_amount", "currency", "created_at"], - "definitions": { - "user": USER_SCHEMA, - "product": PRODUCT_SCHEMA - } -} - -class SchemaValidator: - """JSON Schema validator for structured output.""" - - def __init__(self): - self.schemas = { - 'user': USER_SCHEMA, - 'product': PRODUCT_SCHEMA, - 'order': ORDER_SCHEMA - } - - # Validate schemas themselves - for name, schema in self.schemas.items(): - try: - jsonschema.Draft7Validator.check_schema(schema) - except jsonschema.SchemaError as e: - raise ValueError(f"Invalid schema '{name}': {e}") - - def validate_output(self, data: Any, schema_name: str) -> Dict[str, Any]: - """Validate output data against schema.""" - if schema_name not in self.schemas: - raise ValueError(f"Unknown schema: {schema_name}") - - schema = self.schemas[schema_name] - - try: - validate(instance=data, schema=schema) - return {"valid": True, "data": data} - except ValidationError as e: - return { - "valid": False, - "error": str(e.message), - "path": list(e.absolute_path), - "schema_path": list(e.schema_path) - } - - def validate_and_clean(self, data: Any, schema_name: str) -> Dict[str, Any]: - """Validate and clean data, removing invalid fields.""" - validation_result = self.validate_output(data, schema_name) - - if validation_result["valid"]: - return validation_result - - # Attempt to clean data - cleaned_data = self._clean_data(data, self.schemas[schema_name]) - - # Try validation again - try: - validate(instance=cleaned_data, schema=self.schemas[schema_name]) - return {"valid": True, "data": cleaned_data, "cleaned": True} - except ValidationError as e: - return { - "valid": False, - "error": str(e.message), - "path": list(e.absolute_path), - "schema_path": list(e.schema_path), - "attempted_cleaning": True - } - - def _clean_data(self, data: Any, schema: Dict[str, Any]) -> Any: - """Clean data by removing invalid fields and converting types.""" - if not isinstance(data, dict) or schema.get("type") != "object": - return data - - cleaned = {} - properties = schema.get("properties", {}) - - for key, value in data.items(): - if key in properties: - prop_schema = properties[key] - cleaned_value = self._clean_value(value, prop_schema) - if cleaned_value is not None: - cleaned[key] = cleaned_value - - return cleaned - - def _clean_value(self, value: Any, prop_schema: Dict[str, Any]) -> Any: - """Clean individual value based on property schema.""" - prop_type = prop_schema.get("type") - - if prop_type == "string": - try: - return str(value) - except: - return None - elif prop_type == "integer": - try: - return int(value) - except: - return None - elif prop_type == "number": - try: - return float(value) - except: - return None - elif prop_type == "boolean": - if isinstance(value, bool): - return value - elif isinstance(value, str): - return value.lower() in ('true', '1', 'yes', 'on') - else: - return bool(value) - elif prop_type == "array": - if isinstance(value, list): - return value - else: - return [value] - elif prop_type == "object": - if isinstance(value, dict): - return self._clean_data(value, prop_schema) - else: - return {} - - return value - -# MCP tools with schema validation -validator = SchemaValidator() - -@mcp.tool() -def create_validated_user( - name: str, - email: str, - age: int, - preferences: Dict[str, Any] = None, - roles: List[str] = None -) -> Dict[str, Any]: - """Create user with schema validation.""" - import uuid - - user_data = { - "id": str(uuid.uuid4()), - "name": name, - "email": email, - "age": age, - "preferences": preferences or {"theme": "light", "notifications": True}, - "roles": roles or ["user"] - } - - # Validate against schema - validation_result = validator.validate_output(user_data, "user") - - if validation_result["valid"]: - return {"success": True, "user": validation_result["data"]} - else: - # Try cleaning - clean_result = validator.validate_and_clean(user_data, "user") - if clean_result["valid"]: - return { - "success": True, - "user": clean_result["data"], - "warning": "Data was cleaned during validation" - } - else: - return { - "success": False, - "error": clean_result["error"], - "path": clean_result.get("path", []) - } - -@mcp.tool() -def create_validated_product( - name: str, - price: float, - currency: str = "USD", - description: str = "", - category_name: str = "General", - tags: List[str] = None, - stock_quantity: int = 0 -) -> Dict[str, Any]: - """Create product with schema validation.""" - import uuid - from datetime import datetime - - product_data = { - "id": str(uuid.uuid4()), - "name": name, - "description": description, - "price": price, - "currency": currency.upper(), - "category": { - "id": str(uuid.uuid4()), - "name": category_name - }, - "tags": tags or [], - "in_stock": stock_quantity > 0, - "stock_quantity": stock_quantity, - "created_at": datetime.now().isoformat(), - "updated_at": datetime.now().isoformat() - } - - validation_result = validator.validate_and_clean(product_data, "product") - - if validation_result["valid"]: - return { - "success": True, - "product": validation_result["data"], - "cleaned": validation_result.get("cleaned", False) - } - else: - return { - "success": False, - "error": validation_result["error"], - "path": validation_result.get("path", []), - "details": "Product data failed schema validation" - } - -@mcp.tool() -def validate_data_against_schema( - data: Dict[str, Any], - schema_name: str -) -> Dict[str, Any]: - """Validate arbitrary data against a named schema.""" - if schema_name not in validator.schemas: - return { - "valid": False, - "error": f"Unknown schema: {schema_name}", - "available_schemas": list(validator.schemas.keys()) - } - - result = validator.validate_and_clean(data, schema_name) - - return { - "schema_name": schema_name, - "validation_result": result, - "schema": validator.schemas[schema_name] - } - -if __name__ == "__main__": - mcp.run() -``` - -## Performance optimization - -### Efficient serialization - -```python -""" -Optimized structured output with efficient serialization. -""" - -import json -import pickle -import time -from typing import Any, Dict, List, Optional, Protocol -from dataclasses import dataclass, asdict -from enum import Enum -import orjson # Fast JSON library - -class SerializationFormat(str, Enum): - """Supported serialization formats.""" - JSON = "json" - ORJSON = "orjson" - PICKLE = "pickle" - MSGPACK = "msgpack" - -class Serializer(Protocol): - """Serializer protocol.""" - - def serialize(self, data: Any) -> bytes: - """Serialize data to bytes.""" - ... - - def deserialize(self, data: bytes) -> Any: - """Deserialize bytes to data.""" - ... - -class JsonSerializer: - """Standard JSON serializer.""" - - def serialize(self, data: Any) -> bytes: - return json.dumps(data, default=str).encode('utf-8') - - def deserialize(self, data: bytes) -> Any: - return json.loads(data.decode('utf-8')) - -class OrjsonSerializer: - """Fast orjson serializer.""" - - def serialize(self, data: Any) -> bytes: - return orjson.dumps(data, default=str) - - def deserialize(self, data: bytes) -> Any: - return orjson.loads(data) - -class PickleSerializer: - """Pickle serializer (Python objects only).""" - - def serialize(self, data: Any) -> bytes: - return pickle.dumps(data) - - def deserialize(self, data: bytes) -> Any: - return pickle.loads(data) - -try: - import msgpack - - class MsgPackSerializer: - """MessagePack serializer.""" - - def serialize(self, data: Any) -> bytes: - return msgpack.packb(data, default=str) - - def deserialize(self, data: bytes) -> Any: - return msgpack.unpackb(data, raw=False) - - _MSGPACK_AVAILABLE = True -except ImportError: - _MSGPACK_AVAILABLE = False - -@dataclass -class PerformanceMetrics: - """Performance metrics for serialization.""" - format: str - serialization_time: float - deserialization_time: float - serialized_size: int - data_size_estimate: int - -class OptimizedStructuredOutput: - """Optimized structured output handler.""" - - def __init__(self, preferred_format: SerializationFormat = SerializationFormat.ORJSON): - self.preferred_format = preferred_format - self.serializers = { - SerializationFormat.JSON: JsonSerializer(), - SerializationFormat.ORJSON: OrjsonSerializer(), - SerializationFormat.PICKLE: PickleSerializer(), - } - - if _MSGPACK_AVAILABLE: - self.serializers[SerializationFormat.MSGPACK] = MsgPackSerializer() - - self.cache: Dict[str, tuple] = {} # Cache for expensive computations - self.performance_data: List[PerformanceMetrics] = [] - - def format_output( - self, - data: Any, - format_type: Optional[SerializationFormat] = None, - compress: bool = False - ) -> Dict[str, Any]: - """Format output with performance optimization.""" - format_type = format_type or self.preferred_format - - if format_type not in self.serializers: - raise ValueError(f"Unsupported format: {format_type}") - - # Check cache - cache_key = f"{format_type}:{hash(str(data))}" - if cache_key in self.cache: - cached_result, timestamp = self.cache[cache_key] - if time.time() - timestamp < 300: # 5 minute cache - return cached_result - - serializer = self.serializers[format_type] - - # Measure serialization performance - start_time = time.time() - serialized_data = serializer.serialize(data) - serialization_time = time.time() - start_time - - # Measure deserialization performance - start_time = time.time() - deserialized_data = serializer.deserialize(serialized_data) - deserialization_time = time.time() - start_time - - # Optional compression - if compress: - import gzip - compressed_data = gzip.compress(serialized_data) - compression_ratio = len(compressed_data) / len(serialized_data) - else: - compressed_data = serialized_data - compression_ratio = 1.0 - - # Record performance metrics - metrics = PerformanceMetrics( - format=format_type.value, - serialization_time=serialization_time, - deserialization_time=deserialization_time, - serialized_size=len(serialized_data), - data_size_estimate=len(str(data)) - ) - self.performance_data.append(metrics) - - result = { - "data": data, - "format": format_type.value, - "serialized_size": len(serialized_data), - "compressed_size": len(compressed_data), - "compression_ratio": compression_ratio, - "performance": { - "serialization_time_ms": serialization_time * 1000, - "deserialization_time_ms": deserialization_time * 1000, - "total_time_ms": (serialization_time + deserialization_time) * 1000 - } - } - - # Cache result - self.cache[cache_key] = (result, time.time()) - - return result - - def benchmark_formats(self, test_data: Any) -> Dict[str, PerformanceMetrics]: - """Benchmark different serialization formats.""" - results = {} - - for format_type in self.serializers: - try: - start_time = time.time() - formatted = self.format_output(test_data, format_type) - total_time = time.time() - start_time - - results[format_type.value] = { - "serialization_time_ms": formatted["performance"]["serialization_time_ms"], - "deserialization_time_ms": formatted["performance"]["deserialization_time_ms"], - "total_time_ms": formatted["performance"]["total_time_ms"], - "serialized_size": formatted["serialized_size"], - "efficiency_score": formatted["serialized_size"] / (total_time * 1000) # Size per ms - } - except Exception as e: - results[format_type.value] = {"error": str(e)} - - return results - - def get_performance_summary(self) -> Dict[str, Any]: - """Get performance summary across all operations.""" - if not self.performance_data: - return {"message": "No performance data available"} - - by_format = {} - for metrics in self.performance_data: - if metrics.format not in by_format: - by_format[metrics.format] = [] - by_format[metrics.format].append(metrics) - - summary = {} - for format_name, format_metrics in by_format.items(): - summary[format_name] = { - "operations_count": len(format_metrics), - "avg_serialization_time_ms": sum(m.serialization_time for m in format_metrics) / len(format_metrics) * 1000, - "avg_deserialization_time_ms": sum(m.deserialization_time for m in format_metrics) / len(format_metrics) * 1000, - "avg_serialized_size": sum(m.serialized_size for m in format_metrics) / len(format_metrics), - "total_data_processed": sum(m.data_size_estimate for m in format_metrics) - } - - return summary - -# Global optimizer instance -output_optimizer = OptimizedStructuredOutput() - -# Optimized tools -@mcp.tool() -def create_large_dataset( - size: int = 1000, - format_type: str = "orjson", - compress: bool = False -) -> Dict[str, Any]: - """Create large dataset with optimized output formatting.""" - import uuid - from datetime import datetime, timedelta - import random - - # Generate large dataset - dataset = [] - base_date = datetime.now() - - for i in range(size): - record = { - "id": str(uuid.uuid4()), - "name": f"Record {i}", - "value": random.uniform(0, 1000), - "category": random.choice(["A", "B", "C", "D"]), - "timestamp": (base_date + timedelta(minutes=i)).isoformat(), - "metadata": { - "source": f"source_{i % 10}", - "tags": [f"tag_{j}" for j in range(random.randint(1, 5))], - "properties": { - "x": random.uniform(-100, 100), - "y": random.uniform(-100, 100), - "z": random.uniform(-100, 100) - } - } - } - dataset.append(record) - - # Format with optimization - format_enum = SerializationFormat(format_type) - result = output_optimizer.format_output( - {"dataset": dataset, "size": size, "generated_at": datetime.now().isoformat()}, - format_enum, - compress - ) - - return result - -@mcp.tool() -def benchmark_serialization_formats( - data_size: int = 100 -) -> Dict[str, Any]: - """Benchmark different serialization formats.""" - # Create test data - test_data = { - "items": [ - { - "id": i, - "name": f"Item {i}", - "value": i * 1.5, - "active": i % 2 == 0, - "tags": [f"tag_{j}" for j in range(i % 5 + 1)] - } - for i in range(data_size) - ], - "metadata": { - "created_at": "2024-01-01T00:00:00Z", - "version": "1.0.0", - "config": { - "setting1": True, - "setting2": 42, - "setting3": "value" - } - } - } - - # Run benchmark - benchmark_results = output_optimizer.benchmark_formats(test_data) - - # Get recommendations - fastest_serialization = min( - benchmark_results.items(), - key=lambda x: x[1].get("serialization_time_ms", float('inf')) if isinstance(x[1], dict) else float('inf') - ) - - smallest_size = min( - benchmark_results.items(), - key=lambda x: x[1].get("serialized_size", float('inf')) if isinstance(x[1], dict) else float('inf') - ) - - return { - "test_data_size": data_size, - "benchmark_results": benchmark_results, - "recommendations": { - "fastest_serialization": fastest_serialization[0], - "smallest_output": smallest_size[0], - "recommended_for_speed": fastest_serialization[0], - "recommended_for_size": smallest_size[0] - }, - "performance_summary": output_optimizer.get_performance_summary() - } - -@mcp.tool() -def get_optimization_stats() -> Dict[str, Any]: - """Get optimization and performance statistics.""" - return { - "cache_size": len(output_optimizer.cache), - "total_operations": len(output_optimizer.performance_data), - "performance_summary": output_optimizer.get_performance_summary(), - "available_formats": [fmt.value for fmt in SerializationFormat if fmt in output_optimizer.serializers], - "current_preferred_format": output_optimizer.preferred_format.value, - "msgpack_available": _MSGPACK_AVAILABLE - } - -if __name__ == "__main__": - mcp.run() -``` - -## Best practices - -### Design guidelines - -- **Schema first** - Define clear schemas before implementation -- **Validation layers** - Validate at multiple levels (input, processing, output) -- **Error handling** - Provide detailed validation error messages -- **Documentation** - Include schema documentation in tool descriptions -- **Versioning** - Plan for schema evolution and backward compatibility - -### Performance considerations - -- **Lazy validation** - Validate only when necessary -- **Efficient serialization** - Choose appropriate serialization formats -- **Caching** - Cache validated and serialized outputs -- **Streaming** - Use streaming for large datasets -- **Compression** - Compress large outputs when appropriate - -### Schema evolution - -- **Backward compatibility** - Ensure new schemas work with old data -- **Optional fields** - Use optional fields for new additions -- **Default values** - Provide sensible defaults for new fields -- **Deprecation** - Plan deprecation paths for old fields -- **Migration** - Provide data migration utilities - -## Next steps - -- **[Completions](completions.md)** - LLM integration with structured output -- **[Low-level server](low-level-server.md)** - Advanced server implementation -- **[Parsing results](parsing-results.md)** - Client-side result processing -- **[Authentication](authentication.md)** - Secure structured data exchange \ No newline at end of file diff --git a/docs/tools.md b/docs/tools.md deleted file mode 100644 index 05b596e5a..000000000 --- a/docs/tools.md +++ /dev/null @@ -1,671 +0,0 @@ -# Tools - -Tools are functions that LLMs can call to perform actions and computations. Unlike resources, tools can have side effects and perform operations that change state. - -## What are tools? - -Tools enable LLMs to: - -- **Perform computations** - Mathematical operations, data processing -- **Interact with external systems** - APIs, databases, file systems -- **Execute actions** - Send emails, create files, update records -- **Process data** - Transform, validate, or analyze information - -## Basic tool creation - -### Simple tools - -```python -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP("Calculator") - -@mcp.tool() -def add(a: int, b: int) -> int: - """Add two numbers together.""" - return a + b - -@mcp.tool() -def multiply(a: int, b: int) -> int: - """Multiply two numbers together.""" - return a * b - -@mcp.tool() -def calculate_average(numbers: list[float]) -> float: - """Calculate the average of a list of numbers.""" - if not numbers: - raise ValueError("Cannot calculate average of empty list") - return sum(numbers) / len(numbers) -``` - -### Tools with default parameters - -```python -@mcp.tool() -def greet(name: str, greeting: str = "Hello", punctuation: str = "!") -> str: - """Greet someone with a customizable message.""" - return f"{greeting}, {name}{punctuation}" - -@mcp.tool() -def format_currency( - amount: float, - currency: str = "USD", - decimal_places: int = 2 -) -> str: - """Format a number as currency.""" - symbol_map = { - "USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥" - } - symbol = symbol_map.get(currency, currency) - return f"{symbol}{amount:.{decimal_places}f}" -``` - -## Structured output - -Tools can return structured data that's automatically validated and typed: - -### Using Pydantic models - -```python -from pydantic import BaseModel, Field -from typing import Optional - -class WeatherData(BaseModel): - """Weather information structure.""" - temperature: float = Field(description="Temperature in Celsius") - humidity: float = Field(description="Humidity percentage", ge=0, le=100) - condition: str = Field(description="Weather condition") - wind_speed: float = Field(description="Wind speed in km/h", ge=0) - location: str = Field(description="Location name") - -@mcp.tool() -def get_weather(city: str) -> WeatherData: - """Get weather data for a city - returns structured data.""" - # Simulate weather API call - return WeatherData( - temperature=22.5, - humidity=65.0, - condition="Partly cloudy", - wind_speed=12.3, - location=city - ) -``` - -### Using TypedDict - -```python -from typing import TypedDict - -class LocationInfo(TypedDict): - latitude: float - longitude: float - name: str - country: str - -@mcp.tool() -def get_location(address: str) -> LocationInfo: - """Get location coordinates for an address.""" - # Simulate geocoding API - return LocationInfo( - latitude=51.5074, - longitude=-0.1278, - name="London", - country="United Kingdom" - ) -``` - -### Using dataclasses - -```python -from dataclasses import dataclass -from typing import Optional - -@dataclass -class UserProfile: - """User profile information.""" - name: str - age: int - email: Optional[str] = None - verified: bool = False - -@mcp.tool() -def create_user_profile(name: str, age: int, email: Optional[str] = None) -> UserProfile: - """Create a new user profile.""" - return UserProfile( - name=name, - age=age, - email=email, - verified=email is not None - ) -``` - -### Simple structured data - -```python -@mcp.tool() -def analyze_text(text: str) -> dict[str, int]: - """Analyze text and return statistics.""" - words = text.split() - return { - "character_count": len(text), - "word_count": len(words), - "sentence_count": text.count('.') + text.count('!') + text.count('?'), - "paragraph_count": text.count('\\n\\n') + 1 - } - -@mcp.tool() -def get_prime_numbers(limit: int) -> list[int]: - """Get all prime numbers up to a limit.""" - if limit < 2: - return [] - - primes = [] - for num in range(2, limit + 1): - for i in range(2, int(num ** 0.5) + 1): - if num % i == 0: - break - else: - primes.append(num) - - return primes -``` - -## Advanced tool patterns - -### Tools with context - -Access request context, logging, and progress reporting: - -```python -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - -mcp = FastMCP("Advanced Tools") - -@mcp.tool() -async def long_running_task( - task_name: str, - steps: int, - ctx: Context[ServerSession, None] -) -> str: - """Execute a long-running task with progress updates.""" - await ctx.info(f"Starting task: {task_name}") - - for i in range(steps): - # Report progress - progress = (i + 1) / steps - await ctx.report_progress( - progress=progress, - total=1.0, - message=f"Step {i + 1}/{steps}: Processing..." - ) - - # Simulate work - await asyncio.sleep(0.1) - await ctx.debug(f"Completed step {i + 1}") - - await ctx.info(f"Task '{task_name}' completed successfully") - return f"Task '{task_name}' completed in {steps} steps" - -@mcp.tool() -async def read_and_process(resource_uri: str, ctx: Context) -> str: - """Read a resource and process its content.""" - try: - # Read a resource from within a tool - resource_content = await ctx.read_resource(resource_uri) - - # Process the content - content = resource_content.contents[0] - if hasattr(content, 'text'): - text = content.text - word_count = len(text.split()) - await ctx.info(f"Processed {word_count} words from {resource_uri}") - return f"Processed resource with {word_count} words" - else: - return "Resource content was not text" - - except Exception as e: - await ctx.error(f"Failed to process resource: {e}") - raise -``` - -### Database integration tools - -```python -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from dataclasses import dataclass - -class Database: - """Mock database class.""" - - @classmethod - async def connect(cls) -> "Database": - return cls() - - async def disconnect(self) -> None: - pass - - async def create_user(self, name: str, email: str) -> dict: - return {"id": "123", "name": name, "email": email, "created": "2024-01-01"} - - async def get_user(self, user_id: str) -> dict | None: - return {"id": user_id, "name": "John Doe", "email": "john@example.com"} - - async def update_user(self, user_id: str, **updates) -> dict: - return {"id": user_id, **updates, "updated": "2024-01-01"} - -@dataclass -class AppContext: - db: Database - -@asynccontextmanager -async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: - db = await Database.connect() - try: - yield AppContext(db=db) - finally: - await db.disconnect() - -mcp = FastMCP("Database Tools", lifespan=app_lifespan) - -@mcp.tool() -async def create_user( - name: str, - email: str, - ctx: Context[ServerSession, AppContext] -) -> dict: - """Create a new user in the database.""" - db = ctx.request_context.lifespan_context.db - - # Validate email format - if "@" not in email: - raise ValueError("Invalid email format") - - user = await db.create_user(name, email) - await ctx.info(f"Created user {name} with ID {user['id']}") - return user - -@mcp.tool() -async def get_user( - user_id: str, - ctx: Context[ServerSession, AppContext] -) -> dict: - """Retrieve user information by ID.""" - db = ctx.request_context.lifespan_context.db - - user = await db.get_user(user_id) - if not user: - raise ValueError(f"User with ID {user_id} not found") - - return user - -@mcp.tool() -async def update_user( - user_id: str, - name: Optional[str] = None, - email: Optional[str] = None, - ctx: Context[ServerSession, AppContext] -) -> dict: - """Update user information.""" - db = ctx.request_context.lifespan_context.db - - # Build updates dict - updates = {} - if name is not None: - updates["name"] = name - if email is not None: - if "@" not in email: - raise ValueError("Invalid email format") - updates["email"] = email - - if not updates: - raise ValueError("No updates provided") - - user = await db.update_user(user_id, **updates) - await ctx.info(f"Updated user {user_id}") - return user -``` - -### File system tools - -```python -import os -from pathlib import Path -from typing import List - -# Security: Define allowed directory -ALLOWED_DIR = Path("/safe/directory") - -@mcp.tool() -def create_file(filename: str, content: str) -> str: - """Create a new file with the given content.""" - # Security validation - if ".." in filename or "/" in filename: - raise ValueError("Invalid filename: path traversal not allowed") - - file_path = ALLOWED_DIR / filename - - # Check if file already exists - if file_path.exists(): - raise ValueError(f"File {filename} already exists") - - # Create file - file_path.write_text(content, encoding="utf-8") - return f"Created file {filename} ({len(content)} characters)" - -@mcp.tool() -def read_file(filename: str) -> str: - """Read the contents of a file.""" - if ".." in filename or "/" in filename: - raise ValueError("Invalid filename") - - file_path = ALLOWED_DIR / filename - - if not file_path.exists(): - raise ValueError(f"File {filename} not found") - - try: - content = file_path.read_text(encoding="utf-8") - return content - except UnicodeDecodeError: - raise ValueError(f"File {filename} is not a text file") - -@mcp.tool() -def list_files() -> List[str]: - """List all files in the allowed directory.""" - try: - files = [f.name for f in ALLOWED_DIR.iterdir() if f.is_file()] - return sorted(files) - except OSError as e: - raise ValueError(f"Cannot list files: {e}") - -@mcp.tool() -def delete_file(filename: str) -> str: - """Delete a file.""" - if ".." in filename or "/" in filename: - raise ValueError("Invalid filename") - - file_path = ALLOWED_DIR / filename - - if not file_path.exists(): - raise ValueError(f"File {filename} not found") - - file_path.unlink() - return f"Deleted file {filename}" -``` - -### API integration tools - -```python -import aiohttp -import json -from typing import Any - -@mcp.tool() -async def fetch_json(url: str, headers: Optional[dict[str, str]] = None) -> dict[str, Any]: - """Fetch JSON data from a URL.""" - # Security: validate URL - if not url.startswith(("http://", "https://")): - raise ValueError("URL must use HTTP or HTTPS") - - async with aiohttp.ClientSession() as session: - try: - async with session.get(url, headers=headers or {}) as response: - if response.status != 200: - raise ValueError(f"HTTP {response.status}: {response.reason}") - - data = await response.json() - return data - - except aiohttp.ClientError as e: - raise ValueError(f"Request failed: {e}") - -@mcp.tool() -async def send_webhook( - url: str, - data: dict[str, Any], - method: str = "POST" -) -> dict[str, Any]: - """Send a webhook with JSON data.""" - if not url.startswith(("http://", "https://")): - raise ValueError("URL must use HTTP or HTTPS") - - if method not in ["POST", "PUT", "PATCH"]: - raise ValueError("Method must be POST, PUT, or PATCH") - - async with aiohttp.ClientSession() as session: - try: - async with session.request( - method, - url, - json=data, - headers={"Content-Type": "application/json"} - ) as response: - response_data = { - "status": response.status, - "headers": dict(response.headers), - } - - if response.headers.get("content-type", "").startswith("application/json"): - response_data["data"] = await response.json() - else: - response_data["text"] = await response.text() - - return response_data - - except aiohttp.ClientError as e: - raise ValueError(f"Webhook failed: {e}") -``` - -## Error handling and validation - -### Input validation with Pydantic - -```python -from pydantic import Field, validator -from typing import Annotated - -@mcp.tool() -def validate_email( - email: Annotated[str, Field(description="Email address to validate")] -) -> dict[str, bool]: - """Validate an email address format.""" - import re - - pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$' - is_valid = bool(re.match(pattern, email)) - - return { - "email": email, - "is_valid": is_valid, - "has_at_symbol": "@" in email, - "has_domain": "." in email.split("@")[-1] if "@" in email else False - } - -@mcp.tool() -def process_age( - age: Annotated[int, Field(ge=0, le=150, description="Person's age in years")] -) -> str: - """Process a person's age with automatic validation.""" - if age < 18: - return f"Minor: {age} years old" - elif age < 65: - return f"Adult: {age} years old" - else: - return f"Senior: {age} years old" -``` - -### Custom error handling - -```python -class CalculationError(Exception): - """Custom exception for calculation errors.""" - pass - -@mcp.tool() -def safe_divide(a: float, b: float) -> dict[str, Any]: - """Divide two numbers with comprehensive error handling.""" - try: - if b == 0: - raise CalculationError("Division by zero is not allowed") - - result = a / b - - return { - "dividend": a, - "divisor": b, - "quotient": result, - "success": True - } - - except CalculationError as e: - return { - "dividend": a, - "divisor": b, - "error": str(e), - "success": False - } - -@mcp.tool() -async def robust_api_call(endpoint: str, ctx: Context) -> dict[str, Any]: - """Make an API call with comprehensive error handling.""" - try: - await ctx.info(f"Calling API endpoint: {endpoint}") - - # Simulate API call - if "error" in endpoint: - raise ValueError("Simulated API error") - - return {"status": "success", "data": "API response"} - - except ValueError as e: - await ctx.error(f"API call failed: {e}") - return {"status": "error", "message": str(e)} - except Exception as e: - await ctx.error(f"Unexpected error: {e}") - return {"status": "error", "message": "Internal server error"} -``` - -## Testing tools - -### Unit testing - -```python -import pytest -from mcp.server.fastmcp import FastMCP - -def test_basic_tool(): - mcp = FastMCP("Test") - - @mcp.tool() - def add(a: int, b: int) -> int: - return a + b - - result = add(2, 3) - assert result == 5 - -def test_tool_with_validation(): - mcp = FastMCP("Test") - - @mcp.tool() - def divide(a: float, b: float) -> float: - if b == 0: - raise ValueError("Cannot divide by zero") - return a / b - - assert divide(10, 2) == 5.0 - - with pytest.raises(ValueError, match="Cannot divide by zero"): - divide(10, 0) - -@pytest.mark.asyncio -async def test_async_tool(): - mcp = FastMCP("Test") - - @mcp.tool() - async def async_add(a: int, b: int) -> int: - return a + b - - result = await async_add(3, 4) - assert result == 7 -``` - -### Integration testing - -```python -import asyncio -from unittest.mock import AsyncMock - -@pytest.mark.asyncio -async def test_database_tool(): - # Mock database - mock_db = AsyncMock() - mock_db.create_user.return_value = {"id": "123", "name": "Test User"} - - # Test the tool function directly - mcp = FastMCP("Test") - - @mcp.tool() - async def create_user_tool(name: str, email: str) -> dict: - user = await mock_db.create_user(name, email) - return user - - result = await create_user_tool("Test User", "test@example.com") - assert result["name"] == "Test User" - mock_db.create_user.assert_called_once_with("Test User", "test@example.com") -``` - -## Best practices - -### Design principles - -- **Single responsibility** - Each tool should do one thing well -- **Clear naming** - Use descriptive function and parameter names -- **Comprehensive docstrings** - Explain what the tool does and its parameters -- **Input validation** - Validate all parameters thoroughly -- **Error handling** - Provide clear, actionable error messages - -### Performance considerations - -- **Use async/await** for I/O operations -- **Implement timeouts** for external API calls -- **Cache expensive computations** where appropriate -- **Batch operations** when possible - -### Security guidelines - -- **Validate all inputs** - Never trust user input -- **Sanitize file paths** - Prevent directory traversal attacks -- **Limit resource access** - Use allow-lists for files and URLs -- **Handle authentication** - Verify permissions for sensitive operations -- **Log security events** - Track access and errors - -## Common use cases - -### Mathematical tools -- Calculations and formulas -- Statistical analysis -- Data transformations - -### Data processing tools -- Text analysis and manipulation -- File operations -- Format conversions - -### Integration tools -- API calls and webhooks -- Database operations -- External service interactions - -### Utility tools -- Validation and formatting -- System information -- Configuration management - -## Next steps - -- **[Working with context](context.md)** - Access request context and capabilities -- **[Structured output patterns](structured-output.md)** - Advanced typing techniques -- **[Server deployment](running-servers.md)** - Deploy tools in production -- **[Authentication](authentication.md)** - Secure tool access \ No newline at end of file diff --git a/docs/writing-clients.md b/docs/writing-clients.md deleted file mode 100644 index f0e060883..000000000 --- a/docs/writing-clients.md +++ /dev/null @@ -1,1078 +0,0 @@ -# Writing clients - -Learn how to build MCP clients that can connect to servers using various transports and handle the full MCP protocol. - -## Overview - -MCP clients enable applications to: - -- **Connect to MCP servers** using stdio, SSE, or Streamable HTTP transports -- **Discover capabilities** - List available tools, resources, and prompts -- **Execute operations** - Call tools, read resources, and get prompts -- **Handle real-time updates** - Receive notifications and progress updates - -## Basic client setup - -### stdio client - -The simplest way to connect to MCP servers: - -```python -""" -Basic stdio client example. -""" - -import asyncio -import os -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client - -async def basic_stdio_client(): - """Connect to an MCP server via stdio.""" - - # Configure server parameters - server_params = StdioServerParameters( - command="uv", - args=["run", "server", "quickstart", "stdio"], - env={"UV_INDEX": os.environ.get("UV_INDEX", "")} - ) - - # Connect to server - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - # Initialize the connection - init_result = await session.initialize() - print(f"Connected to: {init_result.serverInfo.name}") - print(f"Protocol version: {init_result.protocolVersion}") - - # List available tools - tools = await session.list_tools() - print(f"Available tools: {[tool.name for tool in tools.tools]}") - - # Call a tool - if tools.tools: - tool_name = tools.tools[0].name - result = await session.call_tool(tool_name, {"a": 5, "b": 3}) - - # Handle result - if result.content: - content = result.content[0] - if hasattr(content, 'text'): - print(f"Tool result: {content.text}") - -if __name__ == "__main__": - asyncio.run(basic_stdio_client()) -``` - -### HTTP client - -Connect to servers using HTTP transports: - -```python -""" -HTTP client using Streamable HTTP transport. -""" - -import asyncio -from mcp import ClientSession -from mcp.client.streamable_http import streamablehttp_client - -async def http_client_example(): - """Connect to MCP server via HTTP.""" - - server_url = "http://localhost:8000/mcp" - - async with streamablehttp_client(server_url) as (read, write, session_info): - async with ClientSession(read, write) as session: - # Initialize connection - await session.initialize() - - # Get server capabilities - print(f"Server capabilities: {session.server_capabilities}") - - # List resources - resources = await session.list_resources() - print(f"Available resources: {[r.uri for r in resources.resources]}") - - # Read a resource - if resources.resources: - resource_uri = resources.resources[0].uri - content = await session.read_resource(resource_uri) - - for item in content.contents: - if hasattr(item, 'text'): - print(f"Resource content: {item.text[:100]}...") - -if __name__ == "__main__": - asyncio.run(http_client_example()) -``` - -## Advanced client patterns - -### Error handling and retries - -```python -""" -Robust client with error handling and retries. -""" - -import asyncio -import logging -from typing import Any -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client -from mcp.shared.exceptions import McpError - -logger = logging.getLogger(__name__) - -class RobustMcpClient: - """MCP client with robust error handling.""" - - def __init__(self, server_params: StdioServerParameters, max_retries: int = 3): - self.server_params = server_params - self.max_retries = max_retries - self.session: ClientSession | None = None - - async def connect(self) -> bool: - """Connect to the server with retries.""" - for attempt in range(self.max_retries): - try: - logger.info(f"Connection attempt {attempt + 1}/{self.max_retries}") - - self.read_stream, self.write_stream = await stdio_client( - self.server_params - ).__aenter__() - - self.session = ClientSession(self.read_stream, self.write_stream) - await self.session.__aenter__() - await self.session.initialize() - - logger.info("Successfully connected to MCP server") - return True - - except Exception as e: - logger.warning(f"Connection attempt {attempt + 1} failed: {e}") - if attempt == self.max_retries - 1: - logger.error("All connection attempts failed") - return False - - await asyncio.sleep(2 ** attempt) # Exponential backoff - - return False - - async def call_tool_safely(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]: - """Call tool with error handling.""" - if not self.session: - raise RuntimeError("Not connected to server") - - try: - result = await self.session.call_tool(name, arguments) - - if result.isError: - return { - "success": False, - "error": "Tool execution failed", - "content": [item.text if hasattr(item, 'text') else str(item) - for item in result.content] - } - - # Extract content - content_items = [] - for item in result.content: - if hasattr(item, 'text'): - content_items.append(item.text) - elif hasattr(item, 'data'): - content_items.append(f"") - else: - content_items.append(str(item)) - - return { - "success": True, - "content": content_items, - "structured": result.structuredContent if hasattr(result, 'structuredContent') else None - } - - except McpError as e: - logger.error(f"MCP error calling tool {name}: {e}") - return {"success": False, "error": f"MCP error: {e}"} - - except Exception as e: - logger.error(f"Unexpected error calling tool {name}: {e}") - return {"success": False, "error": f"Unexpected error: {e}"} - - async def read_resource_safely(self, uri: str) -> dict[str, Any]: - """Read resource with error handling.""" - if not self.session: - raise RuntimeError("Not connected to server") - - try: - result = await self.session.read_resource(uri) - - content_items = [] - for item in result.contents: - if hasattr(item, 'text'): - content_items.append({"type": "text", "content": item.text}) - elif hasattr(item, 'data'): - content_items.append({ - "type": "binary", - "size": len(item.data), - "mime_type": getattr(item, 'mimeType', 'application/octet-stream') - }) - else: - content_items.append({"type": "unknown", "content": str(item)}) - - return {"success": True, "contents": content_items} - - except Exception as e: - logger.error(f"Error reading resource {uri}: {e}") - return {"success": False, "error": str(e)} - - async def disconnect(self): - """Clean disconnect from server.""" - if self.session: - try: - await self.session.__aexit__(None, None, None) - except: - pass - self.session = None - -# Usage example -async def robust_client_example(): - """Example using the robust client.""" - server_params = StdioServerParameters( - command="python", - args=["my_server.py"] - ) - - client = RobustMcpClient(server_params) - - if await client.connect(): - # Use the client - result = await client.call_tool_safely("add", {"a": 10, "b": 20}) - print(f"Tool result: {result}") - - resource_result = await client.read_resource_safely("config://settings") - print(f"Resource result: {resource_result}") - - await client.disconnect() - else: - print("Failed to connect to server") - -if __name__ == "__main__": - logging.basicConfig(level=logging.INFO) - asyncio.run(robust_client_example()) -``` - -### Interactive client - -```python -""" -Interactive MCP client with command-line interface. -""" - -import asyncio -import cmd -import json -from typing import Any -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client - -class InteractiveMcpClient(cmd.Cmd): - """Interactive command-line MCP client.""" - - intro = "Welcome to the MCP Interactive Client. Type help or ? for commands." - prompt = "(mcp) " - - def __init__(self): - super().__init__() - self.session: ClientSession | None = None - self.connected = False - self.tools = [] - self.resources = [] - self.prompts = [] - - def do_connect(self, args: str): - """Connect to MCP server: connect [args...]""" - if not args: - print("Usage: connect [args...]") - return - - parts = args.split() - command = parts[0] - server_args = parts[1:] if len(parts) > 1 else [] - - asyncio.run(self._connect(command, server_args)) - - async def _connect(self, command: str, args: list[str]): - """Async connect implementation.""" - try: - server_params = StdioServerParameters(command=command, args=args) - - self.read_stream, self.write_stream = await stdio_client( - server_params - ).__aenter__() - - self.session = ClientSession(self.read_stream, self.write_stream) - await self.session.__aenter__() - - init_result = await self.session.initialize() - - print(f"Connected to: {init_result.serverInfo.name}") - print(f"Version: {init_result.serverInfo.version}") - - self.connected = True - await self._refresh_capabilities() - - except Exception as e: - print(f"Connection failed: {e}") - - async def _refresh_capabilities(self): - """Refresh server capabilities.""" - if not self.session: - return - - try: - # List tools - tools_response = await self.session.list_tools() - self.tools = tools_response.tools - - # List resources - resources_response = await self.session.list_resources() - self.resources = resources_response.resources - - # List prompts - prompts_response = await self.session.list_prompts() - self.prompts = prompts_response.prompts - - print(f"Discovered: {len(self.tools)} tools, {len(self.resources)} resources, {len(self.prompts)} prompts") - - except Exception as e: - print(f"Error refreshing capabilities: {e}") - - def do_list(self, args: str): - """List available tools, resources, or prompts: list [tools|resources|prompts]""" - if not self.connected: - print("Not connected to server") - return - - if not args or args == "tools": - print("Available tools:") - for tool in self.tools: - print(f" {tool.name}: {tool.description}") - - elif args == "resources": - print("Available resources:") - for resource in self.resources: - print(f" {resource.uri}: {resource.name}") - - elif args == "prompts": - print("Available prompts:") - for prompt in self.prompts: - print(f" {prompt.name}: {prompt.description}") - - else: - print("Usage: list [tools|resources|prompts]") - - def do_call(self, args: str): - """Call a tool: call """ - if not self.connected: - print("Not connected to server") - return - - parts = args.split(maxsplit=1) - if len(parts) != 2: - print("Usage: call ") - return - - tool_name, json_args = parts - - try: - arguments = json.loads(json_args) - asyncio.run(self._call_tool(tool_name, arguments)) - except json.JSONDecodeError: - print("Invalid JSON arguments") - - async def _call_tool(self, name: str, arguments: dict[str, Any]): - """Async tool call implementation.""" - try: - result = await self.session.call_tool(name, arguments) - - if result.isError: - print("Tool execution failed:") - for content in result.content: - if hasattr(content, 'text'): - print(f" {content.text}") - else: - print("Tool result:") - for content in result.content: - if hasattr(content, 'text'): - print(f" {content.text}") - - # Show structured content if available - if hasattr(result, 'structuredContent') and result.structuredContent: - print("Structured result:") - print(f" {json.dumps(result.structuredContent, indent=2)}") - - except Exception as e: - print(f"Error calling tool: {e}") - - def do_read(self, args: str): - """Read a resource: read """ - if not self.connected: - print("Not connected to server") - return - - if not args: - print("Usage: read ") - return - - asyncio.run(self._read_resource(args)) - - async def _read_resource(self, uri: str): - """Async resource read implementation.""" - try: - result = await self.session.read_resource(uri) - - print(f"Resource content for {uri}:") - for content in result.contents: - if hasattr(content, 'text'): - print(content.text) - elif hasattr(content, 'data'): - print(f"") - - except Exception as e: - print(f"Error reading resource: {e}") - - def do_prompt(self, args: str): - """Get a prompt: prompt """ - if not self.connected: - print("Not connected to server") - return - - parts = args.split(maxsplit=1) - if len(parts) < 1: - print("Usage: prompt [json_arguments]") - return - - prompt_name = parts[0] - arguments = {} - - if len(parts) == 2: - try: - arguments = json.loads(parts[1]) - except json.JSONDecodeError: - print("Invalid JSON arguments") - return - - asyncio.run(self._get_prompt(prompt_name, arguments)) - - async def _get_prompt(self, name: str, arguments: dict[str, Any]): - """Async prompt get implementation.""" - try: - result = await self.session.get_prompt(name, arguments) - - print(f"Prompt: {result.description}") - for message in result.messages: - print(f" {message.role}: {message.content.text}") - - except Exception as e: - print(f"Error getting prompt: {e}") - - def do_disconnect(self, args: str): - """Disconnect from server""" - if self.connected: - asyncio.run(self._disconnect()) - print("Disconnected") - else: - print("Not connected") - - async def _disconnect(self): - """Async disconnect implementation.""" - if self.session: - await self.session.__aexit__(None, None, None) - self.session = None - self.connected = False - - def do_quit(self, args: str): - """Quit the client""" - if self.connected: - asyncio.run(self._disconnect()) - return True - - def do_EOF(self, args: str): - """Handle Ctrl+D""" - print() - return self.do_quit(args) - -# Run the interactive client -if __name__ == "__main__": - InteractiveMcpClient().cmdloop() -``` - -## Client-side caching - -### Smart caching client - -```python -""" -MCP client with intelligent caching. -""" - -import asyncio -import hashlib -import time -from typing import Any, Dict, Optional -from dataclasses import dataclass -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client - -@dataclass -class CacheEntry: - """Cache entry with TTL support.""" - data: Any - timestamp: float - ttl: float - -class CachingMcpClient: - """MCP client with caching capabilities.""" - - def __init__(self, server_params: StdioServerParameters, default_ttl: float = 300): - self.server_params = server_params - self.default_ttl = default_ttl - self.cache: Dict[str, CacheEntry] = {} - self.session: Optional[ClientSession] = None - - def _cache_key(self, operation: str, **kwargs) -> str: - """Generate cache key from operation and parameters.""" - key_data = f"{operation}:{kwargs}" - return hashlib.md5(key_data.encode()).hexdigest() - - def _is_cache_valid(self, entry: CacheEntry) -> bool: - """Check if cache entry is still valid.""" - return time.time() - entry.timestamp < entry.ttl - - def _get_cached(self, key: str) -> Optional[Any]: - """Get cached value if valid.""" - if key in self.cache: - entry = self.cache[key] - if self._is_cache_valid(entry): - return entry.data - else: - del self.cache[key] - return None - - def _set_cached(self, key: str, data: Any, ttl: Optional[float] = None): - """Cache data with TTL.""" - if ttl is None: - ttl = self.default_ttl - - self.cache[key] = CacheEntry( - data=data, - timestamp=time.time(), - ttl=ttl - ) - - async def connect(self): - """Connect to the MCP server.""" - self.read_stream, self.write_stream = await stdio_client( - self.server_params - ).__aenter__() - - self.session = ClientSession(self.read_stream, self.write_stream) - await self.session.__aenter__() - await self.session.initialize() - - async def list_tools_cached(self, ttl: float = 600) -> list: - """List tools with caching (tools change infrequently).""" - cache_key = self._cache_key("list_tools") - cached = self._get_cached(cache_key) - - if cached is not None: - return cached - - if not self.session: - raise RuntimeError("Not connected") - - result = await self.session.list_tools() - tools = [ - { - "name": tool.name, - "description": tool.description, - "input_schema": tool.inputSchema - } - for tool in result.tools - ] - - self._set_cached(cache_key, tools, ttl) - return tools - - async def call_tool_cached( - self, - name: str, - arguments: Dict[str, Any], - ttl: Optional[float] = None, - force_refresh: bool = False - ) -> Dict[str, Any]: - """Call tool with optional caching.""" - cache_key = self._cache_key("call_tool", name=name, arguments=arguments) - - if not force_refresh: - cached = self._get_cached(cache_key) - if cached is not None: - return {"cached": True, **cached} - - if not self.session: - raise RuntimeError("Not connected") - - result = await self.session.call_tool(name, arguments) - - # Process result - processed_result = { - "success": not result.isError, - "content": [ - item.text if hasattr(item, 'text') else str(item) - for item in result.content - ] - } - - if hasattr(result, 'structuredContent') and result.structuredContent: - processed_result["structured"] = result.structuredContent - - # Cache successful results if TTL specified - if ttl is not None and processed_result["success"]: - self._set_cached(cache_key, processed_result, ttl) - - return {"cached": False, **processed_result} - - async def read_resource_cached( - self, - uri: str, - ttl: float = 60, - force_refresh: bool = False - ) -> Dict[str, Any]: - """Read resource with caching.""" - cache_key = self._cache_key("read_resource", uri=uri) - - if not force_refresh: - cached = self._get_cached(cache_key) - if cached is not None: - return {"cached": True, **cached} - - if not self.session: - raise RuntimeError("Not connected") - - result = await self.session.read_resource(uri) - - processed_result = { - "uri": uri, - "contents": [ - { - "type": "text" if hasattr(item, 'text') else "binary", - "content": item.text if hasattr(item, 'text') else f"<{len(item.data)} bytes>" - } - for item in result.contents - ] - } - - self._set_cached(cache_key, processed_result, ttl) - return {"cached": False, **processed_result} - - def clear_cache(self, pattern: Optional[str] = None): - """Clear cache entries matching pattern.""" - if pattern is None: - self.cache.clear() - else: - keys_to_remove = [k for k in self.cache.keys() if pattern in k] - for key in keys_to_remove: - del self.cache[key] - - def cache_stats(self) -> Dict[str, Any]: - """Get cache statistics.""" - now = time.time() - valid_entries = sum( - 1 for entry in self.cache.values() - if now - entry.timestamp < entry.ttl - ) - - return { - "total_entries": len(self.cache), - "valid_entries": valid_entries, - "expired_entries": len(self.cache) - valid_entries, - "cache_hit_potential": valid_entries / len(self.cache) if self.cache else 0 - } - -# Usage example -async def caching_client_example(): - """Example using caching client.""" - server_params = StdioServerParameters( - command="python", - args=["server.py"] - ) - - client = CachingMcpClient(server_params, default_ttl=120) - await client.connect() - - # First call - will hit server - result1 = await client.call_tool_cached("add", {"a": 5, "b": 3}, ttl=60) - print(f"First call (cached: {result1['cached']}): {result1['content']}") - - # Second call - will use cache - result2 = await client.call_tool_cached("add", {"a": 5, "b": 3}) - print(f"Second call (cached: {result2['cached']}): {result2['content']}") - - # Resource with caching - resource1 = await client.read_resource_cached("config://settings", ttl=30) - print(f"Resource (cached: {resource1['cached']})") - - # Cache stats - stats = client.cache_stats() - print(f"Cache stats: {stats}") - -if __name__ == "__main__": - asyncio.run(caching_client_example()) -``` - -## Production client patterns - -### Connection pooling client - -```python -""" -Production MCP client with connection pooling. -""" - -import asyncio -import logging -from typing import Any, Dict, List, Optional -from contextlib import asynccontextmanager -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client - -logger = logging.getLogger(__name__) - -class ConnectionPool: - """Connection pool for MCP clients.""" - - def __init__( - self, - server_params: StdioServerParameters, - pool_size: int = 5, - max_retries: int = 3 - ): - self.server_params = server_params - self.pool_size = pool_size - self.max_retries = max_retries - self.available_connections: asyncio.Queue = asyncio.Queue() - self.active_connections: set = set() - self.closed = False - - async def initialize(self): - """Initialize the connection pool.""" - for _ in range(self.pool_size): - connection = await self._create_connection() - if connection: - await self.available_connections.put(connection) - - async def _create_connection(self) -> Optional[ClientSession]: - """Create a new connection with retries.""" - for attempt in range(self.max_retries): - try: - read_stream, write_stream = await stdio_client( - self.server_params - ).__aenter__() - - session = ClientSession(read_stream, write_stream) - await session.__aenter__() - await session.initialize() - - logger.info("Created new MCP connection") - return session - - except Exception as e: - logger.warning(f"Connection attempt {attempt + 1} failed: {e}") - if attempt < self.max_retries - 1: - await asyncio.sleep(2 ** attempt) - - logger.error("Failed to create connection after all retries") - return None - - @asynccontextmanager - async def get_connection(self): - """Get a connection from the pool.""" - if self.closed: - raise RuntimeError("Connection pool is closed") - - try: - # Try to get an available connection - connection = await asyncio.wait_for( - self.available_connections.get(), - timeout=10.0 - ) - - self.active_connections.add(connection) - yield connection - - except asyncio.TimeoutError: - logger.error("Timeout waiting for available connection") - raise - - finally: - # Return connection to pool - if connection in self.active_connections: - self.active_connections.remove(connection) - await self.available_connections.put(connection) - - async def close(self): - """Close all connections in the pool.""" - self.closed = True - - # Close active connections - for connection in list(self.active_connections): - try: - await connection.__aexit__(None, None, None) - except: - pass - - # Close available connections - while not self.available_connections.empty(): - try: - connection = self.available_connections.get_nowait() - await connection.__aexit__(None, None, None) - except: - pass - - logger.info("Connection pool closed") - -class PooledMcpClient: - """MCP client using connection pooling.""" - - def __init__(self, connection_pool: ConnectionPool): - self.pool = connection_pool - - async def call_tool(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: - """Call tool using pooled connection.""" - async with self.pool.get_connection() as session: - result = await session.call_tool(name, arguments) - - return { - "success": not result.isError, - "content": [ - item.text if hasattr(item, 'text') else str(item) - for item in result.content - ], - "structured": getattr(result, 'structuredContent', None) - } - - async def read_resource(self, uri: str) -> Dict[str, Any]: - """Read resource using pooled connection.""" - async with self.pool.get_connection() as session: - result = await session.read_resource(uri) - - return { - "uri": uri, - "contents": [ - item.text if hasattr(item, 'text') else f"" - for item in result.contents - ] - } - - async def list_capabilities(self) -> Dict[str, List[str]]: - """List server capabilities using pooled connection.""" - async with self.pool.get_connection() as session: - tools = await session.list_tools() - resources = await session.list_resources() - prompts = await session.list_prompts() - - return { - "tools": [tool.name for tool in tools.tools], - "resources": [resource.uri for resource in resources.resources], - "prompts": [prompt.name for prompt in prompts.prompts] - } - -# Usage example -async def pooled_client_example(): - """Example using connection pool.""" - server_params = StdioServerParameters( - command="python", - args=["server.py"] - ) - - # Create and initialize connection pool - pool = ConnectionPool(server_params, pool_size=3) - await pool.initialize() - - client = PooledMcpClient(pool) - - try: - # Concurrent operations using pool - tasks = [ - client.call_tool("add", {"a": i, "b": i*2}) - for i in range(10) - ] - - results = await asyncio.gather(*tasks) - - for i, result in enumerate(results): - print(f"Task {i}: {result}") - - # List capabilities - capabilities = await client.list_capabilities() - print(f"Server capabilities: {capabilities}") - - finally: - await pool.close() - -if __name__ == "__main__": - logging.basicConfig(level=logging.INFO) - asyncio.run(pooled_client_example()) -``` - -## Testing client implementations - -### Client testing framework - -```python -""" -Testing framework for MCP clients. -""" - -import pytest -import asyncio -from unittest.mock import Mock, AsyncMock -from mcp import ClientSession -from mcp.types import Tool, InitializeResult, ServerInfo, ListToolsResult - -class MockMcpSession: - """Mock MCP session for testing.""" - - def __init__(self): - self.tools = [ - Tool(name="add", description="Add numbers", inputSchema={}), - Tool(name="multiply", description="Multiply numbers", inputSchema={}) - ] - self.initialized = False - - async def initialize(self): - self.initialized = True - return InitializeResult( - protocolVersion="2025-06-18", - serverInfo=ServerInfo(name="Test Server", version="1.0.0"), - capabilities={} - ) - - async def list_tools(self): - if not self.initialized: - raise RuntimeError("Not initialized") - return ListToolsResult(tools=self.tools) - - async def call_tool(self, name: str, arguments: dict): - if not self.initialized: - raise RuntimeError("Not initialized") - - if name == "add": - result = arguments["a"] + arguments["b"] - elif name == "multiply": - result = arguments["a"] * arguments["b"] - else: - raise ValueError(f"Unknown tool: {name}") - - mock_result = Mock() - mock_result.isError = False - mock_result.content = [Mock(text=str(result))] - mock_result.structuredContent = {"result": result} - - return mock_result - -@pytest.fixture -async def mock_session(): - """Pytest fixture providing mock session.""" - return MockMcpSession() - -@pytest.mark.asyncio -async def test_client_initialization(mock_session): - """Test client initialization.""" - result = await mock_session.initialize() - assert result.serverInfo.name == "Test Server" - assert mock_session.initialized - -@pytest.mark.asyncio -async def test_tool_listing(mock_session): - """Test tool listing.""" - await mock_session.initialize() - tools = await mock_session.list_tools() - - assert len(tools.tools) == 2 - assert tools.tools[0].name == "add" - assert tools.tools[1].name == "multiply" - -@pytest.mark.asyncio -async def test_tool_calling(mock_session): - """Test tool calling.""" - await mock_session.initialize() - - # Test add tool - result = await mock_session.call_tool("add", {"a": 5, "b": 3}) - assert not result.isError - assert result.content[0].text == "8" - - # Test multiply tool - result = await mock_session.call_tool("multiply", {"a": 4, "b": 6}) - assert not result.isError - assert result.content[0].text == "24" - -@pytest.mark.asyncio -async def test_error_handling(mock_session): - """Test error handling.""" - await mock_session.initialize() - - with pytest.raises(ValueError): - await mock_session.call_tool("unknown_tool", {}) - -# Integration test with real client -@pytest.mark.integration -@pytest.mark.asyncio -async def test_real_client_integration(): - """Integration test with real MCP server.""" - # This would connect to a real server for integration testing - # server_params = StdioServerParameters(command="python", args=["test_server.py"]) - # - # async with stdio_client(server_params) as (read, write): - # async with ClientSession(read, write) as session: - # await session.initialize() - # tools = await session.list_tools() - # assert len(tools.tools) > 0 - pass -``` - -## Best practices - -### Client design guidelines - -- **Connection management** - Use connection pooling for high-throughput applications -- **Error handling** - Implement comprehensive error handling and retries -- **Caching** - Cache stable data like tool lists and resource schemas -- **Monitoring** - Track connection health and operation latency -- **Resource cleanup** - Always clean up connections and resources - -### Performance optimization - -- **Async operations** - Use async/await throughout for better concurrency -- **Connection reuse** - Pool connections for multiple operations -- **Batch operations** - Group related operations when possible -- **Smart caching** - Cache responses based on data volatility -- **Timeout management** - Set appropriate timeouts for operations - -### Security considerations - -- **Input validation** - Validate all data before sending to servers -- **Credential management** - Secure handling of authentication tokens -- **Transport security** - Use secure transports (HTTPS, authenticated connections) -- **Error information** - Don't expose sensitive data in error messages -- **Audit logging** - Log all operations for security monitoring - -## Next steps - -- **[OAuth for clients](oauth-clients.md)** - Implement client-side authentication -- **[Display utilities](display-utilities.md)** - UI helpers for client applications -- **[Parsing results](parsing-results.md)** - Handle complex tool responses -- **[Authentication](authentication.md)** - Understanding server-side authentication \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 74f9182c6..9114e8f84 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,35 +12,20 @@ site_url: https://mmacy.github.io/python-sdk nav: - Home: index.md - - Getting started: - - Quickstart: quickstart.md - - Installation: installation.md - - Core concepts: - - Servers: servers.md - - Resources: resources.md - - Tools: tools.md - - Prompts: prompts.md - - Context: context.md - - Advanced features: - - Images: images.md - - Authentication: authentication.md - - Sampling: sampling.md - - Elicitation: elicitation.md - - Progress & logging: progress-logging.md - - Transport & deployment: - - Running servers: running-servers.md - - Streamable HTTP: streamable-http.md - - ASGI integration: asgi-integration.md - - Client development: - - Writing clients: writing-clients.md - - OAuth for clients: oauth-clients.md - - Display utilities: display-utilities.md - - Parsing results: parsing-results.md - - Advanced usage: - - Low-level server: low-level-server.md - - Structured output: structured-output.md - - Completions: completions.md - - API reference: reference/ + - Code examples: + - Getting started: examples-quickstart.md + - Echo servers: examples-echo-servers.md + - Server development: + - Tools: examples-server-tools.md + - Resources: examples-server-resources.md + - Prompts: examples-server-prompts.md + - Structured output: examples-structured-output.md + - Advanced patterns: examples-server-advanced.md + - Transport protocols: + - HTTP transport: examples-transport-http.md + - Low-level servers: examples-lowlevel-servers.md + - Authentication: examples-authentication.md + - Client development: examples-clients.md theme: name: "material" From ced06b7bb82d958e1b0f44148e7c5188ff3e03b9 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 18 Aug 2025 19:45:31 -0700 Subject: [PATCH 07/11] fix up some example matrix issues (#7) --- docs/examples-quickstart.md | 2 +- docs/examples-server-tools.md | 6 ++---- docs/index.md | 11 +++++------ 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/docs/examples-quickstart.md b/docs/examples-quickstart.md index 28b7d1ab6..f0e89905d 100644 --- a/docs/examples-quickstart.md +++ b/docs/examples-quickstart.md @@ -17,7 +17,7 @@ This example shows how to: - Add a dynamic resource that provides data (`greeting://`) - Add a prompt template for LLM interactions (`greet_user`) -## Basic readme example +## Basic server An even simpler starting point: diff --git a/docs/examples-server-tools.md b/docs/examples-server-tools.md index 83a34e23f..7662f5b5e 100644 --- a/docs/examples-server-tools.md +++ b/docs/examples-server-tools.md @@ -65,12 +65,10 @@ Tools for taking and processing screenshots: --8<-- "examples/fastmcp/screenshot.py" ``` -## Text processing tools +## Text messaging tool -Tools for text manipulation and processing: +Tool to send a text message. ```python --8<-- "examples/fastmcp/text_me.py" ``` - -All tool examples demonstrate different aspects of MCP tool development, from basic computation to complex system interactions. \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 4262bbe96..885cf858d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,15 +26,15 @@ Complete API documentation is auto-generated from the source code and available | [Complex input handling](examples-server-tools.md#complex-input-handling) | stdio | — | — | ✅ | — | — | — | — | — | — | — | | [Desktop integration](examples-server-tools.md#desktop-integration) | stdio | ✅ | — | ✅ | — | — | — | — | — | — | — | | [Enhanced echo server](examples-echo-servers.md#enhanced-echo-server) | stdio | ✅ | ✅ | ✅ | — | — | — | — | — | — | — | -| [Memory and state management](examples-server-resources.md#memory-and-state-management) | stdio | — | — | ✅ | — | — | — | — | — | — | ✅ | +| [Memory and state management](examples-server-resources.md#memory-and-state-management) | stdio | — | — | ✅ | — | — | — | — | — | — | — | | [Parameter descriptions](examples-server-tools.md#parameter-descriptions) | stdio | — | — | ✅ | — | — | — | — | — | — | — | -| [Basic readme example](examples-quickstart.md#basic-readme-example) | stdio | ✅ | — | ✅ | — | — | — | — | — | — | — | +| [Basic server](examples-quickstart.md#basic-server) | stdio | ✅ | — | ✅ | — | — | — | — | — | — | — | | [Screenshot tools](examples-server-tools.md#screenshot-tools) | stdio | — | — | ✅ | — | — | — | — | — | — | — | | [Simple echo server](examples-echo-servers.md#simple-echo-server) | stdio | — | — | ✅ | — | — | — | — | — | — | — | -| [Text processing tools](examples-server-tools.md#text-processing-tools) | stdio | — | — | ✅ | — | — | — | — | — | — | ✅ | +| [Text messaging tool](examples-server-tools.md#text-messaging-tool) | stdio | — | — | ✅ | — | — | — | — | — | — | ✅ | | [Unicode and internationalization](examples-server-tools.md#unicode-and-internationalization) | stdio | — | — | ✅ | — | — | — | — | — | — | — | | [Weather service with structured output](examples-structured-output.md#weather-service-with-structured-output) | stdio | — | — | ✅ | — | — | — | — | — | — | — | -| [Complete authentication server](examples-authentication.md#complete-authentication-server) | stdio | — | — | — | — | — | — | — | — | ✅ | — | +| [Complete authentication server](examples-authentication.md#complete-authentication-server) | streamable-http | — | — | — | — | — | — | — | — | ✅ | — | | [Legacy Authorization Server](examples-authentication.md#legacy-authorization-server) | streamable-http | — | — | ✅ | — | — | — | — | — | ✅ | ✅ | | [Resource server with introspection](examples-authentication.md#resource-server-with-introspection) | streamable-http | — | — | ✅ | — | — | — | — | — | ✅ | ✅ | | [Simple prompt server](examples-server-prompts.md#simple-prompt-server) | stdio | — | ✅ | — | — | — | — | — | — | — | — | @@ -44,7 +44,7 @@ Complete API documentation is auto-generated from the source code and available | [Simple tool server](examples-lowlevel-servers.md#simple-tool-server) | stdio | — | — | ✅ | — | — | — | — | — | — | — | | [Low-level structured output](examples-structured-output.md#low-level-structured-output) | stdio | — | — | ✅ | — | — | — | — | — | — | — | | [Basic prompts](examples-server-prompts.md#basic-prompts) | stdio | — | ✅ | — | — | — | — | — | — | — | — | -| [Basic resources](examples-server-resources.md#basic-resources) | stdio | ✅ | — | — | — | — | — | — | — | — | — | +| [Basic resources](examples-server-resources.md#basic-resources) | stdio | ✅ | — | — | — | — | — | — | — | — | ✅ | | [Basic tools](examples-server-tools.md#basic-tools) | stdio | — | — | ✅ | — | — | — | — | — | — | — | | [Completion support](examples-server-advanced.md#completion-support) | stdio | ✅ | ✅ | — | ✅ | — | — | — | — | — | — | | [Direct execution](examples-quickstart.md#direct-execution) | stdio | — | — | ✅ | — | — | — | — | — | — | — | @@ -63,7 +63,6 @@ Complete API documentation is auto-generated from the source code and available | [FastMCP structured output](examples-structured-output.md#fastmcp-structured-output) | stdio | — | — | ✅ | — | — | — | — | — | — | — | | [Tools with context and progress reporting](examples-server-tools.md#tools-with-context-and-progress-reporting) | stdio | — | — | ✅ | — | — | — | ✅ | ✅ | — | — | - ### Clients | File | Transport | Resources | Prompts | Tools | Completions | Sampling | Authentication | From 3add4e6a3148f77ee28e18b33794293181830cbc Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Mon, 18 Aug 2025 20:41:03 -0700 Subject: [PATCH 08/11] misc docstring work (#8) --- CLAUDE.md | 3 +- src/mcp/server/fastmcp/exceptions.py | 2 + src/mcp/server/fastmcp/prompts/base.py | 1 + src/mcp/server/fastmcp/prompts/manager.py | 177 +++++++++++++++++++++- src/mcp/server/fastmcp/server.py | 143 +++++++++-------- src/mcp/server/session.py | 130 ++++++++++++---- src/mcp/types.py | 85 ++++++++++- 7 files changed, 443 insertions(+), 98 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 40ff9dd51..77e2829b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,4 +134,5 @@ This document contains critical information about working with this codebase. Fo - Cleanup blocks (log at debug level) - Always use sentence case for all headings and heading-like text in any Markdown-formatted content, including docstrings. -- Example snippets in docsstrings MUST only appear within the Examples section of the docstring. You MAY include multiple examples in the Examples section. \ No newline at end of file +- Example snippets in docsstrings MUST only appear within the Examples section of the docstring. You MAY include multiple examples in the Examples section. +- Surround all lists, both ordered and unordered, with blank lines. Applies to Markdown in Markdown files as well as docstrings in Python files. \ No newline at end of file diff --git a/src/mcp/server/fastmcp/exceptions.py b/src/mcp/server/fastmcp/exceptions.py index 2bd2cca8e..2a9edefa0 100644 --- a/src/mcp/server/fastmcp/exceptions.py +++ b/src/mcp/server/fastmcp/exceptions.py @@ -23,6 +23,7 @@ class ResourceError(FastMCPError): """Raised when resource operations fail. This exception is raised for resource-related errors such as: + - Resource not found for a given URI - Resource content cannot be read or generated - Resource template parameter validation failures @@ -34,6 +35,7 @@ class ToolError(FastMCPError): """Raised when tool operations fail. This exception is raised for tool-related errors such as: + - Tool not found for a given name - Tool execution failures or unhandled exceptions - Tool registration conflicts or validation errors diff --git a/src/mcp/server/fastmcp/prompts/base.py b/src/mcp/server/fastmcp/prompts/base.py index b45cfc917..cf5d2b0cf 100644 --- a/src/mcp/server/fastmcp/prompts/base.py +++ b/src/mcp/server/fastmcp/prompts/base.py @@ -74,6 +74,7 @@ def from_function( """Create a Prompt from a function. The function can return: + - A string (converted to a message) - A Message object - A dict (converted to a message) diff --git a/src/mcp/server/fastmcp/prompts/manager.py b/src/mcp/server/fastmcp/prompts/manager.py index 6b01d91cd..1aa949b59 100644 --- a/src/mcp/server/fastmcp/prompts/manager.py +++ b/src/mcp/server/fastmcp/prompts/manager.py @@ -1,4 +1,70 @@ -"""Prompt management functionality.""" +"""Prompt management functionality for FastMCP servers. + +This module provides the PromptManager class, which serves as the central registry +for managing prompts in FastMCP servers. Prompts are reusable templates that generate +structured messages for AI model interactions, enabling consistent and parameterized +communication patterns. + +The PromptManager handles the complete lifecycle of prompts: + +- Registration and storage of prompt templates +- Retrieval by name for use in MCP protocol handlers +- Rendering with arguments to produce message sequences +- Duplicate detection and management + +Key concepts: + +- Prompts are created from functions using Prompt.from_function() +- Each prompt has a unique name used for registration and retrieval +- Prompts can accept typed arguments for dynamic content generation +- Rendered prompts return Message objects ready for AI model consumption + +Examples: + Basic prompt management workflow: + + ```python + from mcp.server.fastmcp.prompts import PromptManager, Prompt + + # Initialize the manager + manager = PromptManager() + + # Create a prompt from a function + def analysis_prompt(topic: str, context: str) -> list[str]: + return [ + f"Please analyze the following topic: {topic}", + f"Additional context: {context}", + "Provide a detailed analysis with key insights." + ] + + # Register the prompt + prompt = Prompt.from_function(analysis_prompt) + manager.add_prompt(prompt) + + # Render the prompt with arguments + messages = await manager.render_prompt( + "analysis_prompt", + {"topic": "AI Safety", "context": "Enterprise deployment"} + ) + ``` + + Integration with FastMCP servers: + + ```python + from mcp.server.fastmcp import FastMCP + + mcp = FastMCP("My Server") + + @mcp.prompt() + def code_review(language: str, code: str) -> str: + return f"Review this {language} code for best practices:\\n\\n{code}" + + # The prompt is automatically registered with the server's PromptManager + ``` + +Note: + This module is primarily used internally by FastMCP servers, but can be used + directly for advanced prompt management scenarios or custom MCP implementations. +""" from typing import Any @@ -9,25 +75,91 @@ class PromptManager: - """Manages FastMCP prompts.""" + """Manages prompt registration, storage, and rendering for FastMCP servers. + + The PromptManager is the central registry for all prompts in a FastMCP server. It handles + prompt registration, retrieval by name, listing all available prompts, and rendering + prompts with provided arguments. Prompts are templates that can generate structured + messages for AI model interactions. + + This class is typically used internally by FastMCP servers but can be used directly + for advanced prompt management scenarios. + + Args: + warn_on_duplicate_prompts: Whether to log warnings when attempting to register + a prompt with a name that already exists. Defaults to True. + + Attributes: + warn_on_duplicate_prompts: Whether duplicate prompt warnings are enabled. + + Examples: + Basic usage: + + ```python + from mcp.server.fastmcp.prompts import PromptManager, Prompt + + # Create a manager + manager = PromptManager() + + # Create and add a prompt + def greeting_prompt(name: str) -> str: + return f"Hello, {name}! How can I help you today?" + + prompt = Prompt.from_function(greeting_prompt) + manager.add_prompt(prompt) + + # Render the prompt + messages = await manager.render_prompt("greeting_prompt", {"name": "Alice"}) + ``` + + Disabling duplicate warnings: + + ```python + # Useful in testing scenarios or when you need to replace prompts + manager = PromptManager(warn_on_duplicate_prompts=False) + ``` + """ def __init__(self, warn_on_duplicate_prompts: bool = True): self._prompts: dict[str, Prompt] = {} self.warn_on_duplicate_prompts = warn_on_duplicate_prompts def get_prompt(self, name: str) -> Prompt | None: - """Get prompt by name.""" + """Retrieve a registered prompt by its name. + + Args: + name: The name of the prompt to retrieve. + + Returns: + The Prompt object if found, None if no prompt exists with the given name. + """ return self._prompts.get(name) def list_prompts(self) -> list[Prompt]: - """List all registered prompts.""" + """Get a list of all registered prompts. + + Returns: + A list containing all Prompt objects currently registered with this manager. + Returns an empty list if no prompts are registered. + """ return list(self._prompts.values()) def add_prompt( self, prompt: Prompt, ) -> Prompt: - """Add a prompt to the manager.""" + """Register a prompt with the manager. + + If a prompt with the same name already exists, the existing prompt is returned + without modification. A warning is logged if warn_on_duplicate_prompts is True. + + Args: + prompt: The Prompt object to register. + + Returns: + The registered Prompt object. If a prompt with the same name already exists, + returns the existing prompt instead of the new one. + """ # Check for duplicates existing = self._prompts.get(prompt.name) @@ -40,7 +172,40 @@ def add_prompt( return prompt async def render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> list[Message]: - """Render a prompt by name with arguments.""" + """Render a prompt into a list of messages ready for AI model consumption. + + This method looks up the prompt by name, validates that all required arguments + are provided, executes the prompt function with the given arguments, and converts + the result into a standardized list of Message objects. + + Args: + name: The name of the prompt to render. + arguments: Optional dictionary of arguments to pass to the prompt function. + Must include all required arguments defined by the prompt. + + Returns: + A list of Message objects containing the rendered prompt content. + Each Message has a role ("user" or "assistant") and content. + + Raises: + ValueError: If the prompt name is not found or if required arguments are missing. + + Examples: + Simple prompt without arguments: + + ```python + messages = await manager.render_prompt("welcome") + ``` + + Prompt with arguments: + + ```python + messages = await manager.render_prompt( + "greeting", + {"name": "Alice", "language": "en"} + ) + ``` + """ prompt = self.get_prompt(name) if not prompt: raise ValueError(f"Unknown prompt: {name}") diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index fd80721c8..e29866bb2 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -119,7 +119,7 @@ async def wrap(_: MCPServer[LifespanResultT, Request]) -> AsyncIterator[Lifespan class FastMCP(Generic[LifespanResultT]): - """FastMCP - A high-level, ergonomic interface for creating MCP servers. + """A high-level ergonomic interface for creating MCP servers. FastMCP provides a decorator-based API for building MCP servers with automatic parameter validation, structured output support, and built-in transport handling. @@ -313,7 +313,7 @@ def run( transport: Literal["stdio", "sse", "streamable-http"] = "stdio", mount_path: str | None = None, ) -> None: - """Run the FastMCP server. Note this is a synchronous function. + """Run the FastMCP server. This is a synchronous function. Args: transport: Transport protocol to use ("stdio", "sse", or "streamable-http") @@ -523,19 +523,22 @@ def tool( - If False, unconditionally creates an unstructured tool Example: - @server.tool() - def my_tool(x: int) -> str: - return str(x) - - @server.tool() - def tool_with_context(x: int, ctx: Context) -> str: - ctx.info(f"Processing {x}") - return str(x) - - @server.tool() - async def async_tool(x: int, context: Context) -> str: - await context.report_progress(50, 100) - return str(x) + + ```python + @server.tool() + def my_tool(x: int) -> str: + return str(x) + + @server.tool() + def tool_with_context(x: int, ctx: Context) -> str: + ctx.info(f"Processing {x}") + return str(x) + + @server.tool() + async def async_tool(x: int, context: Context) -> str: + await context.report_progress(50, 100) + return str(x) + ``` """ # Check if user passed function directly instead of calling decorator if callable(name): @@ -565,12 +568,15 @@ def completion(self): - context: Optional CompletionContext with previously resolved arguments Example: - @mcp.completion() - async def handle_completion(ref, argument, context): - if isinstance(ref, ResourceTemplateReference): - # Return completions based on ref, argument, and context - return Completion(values=["option1", "option2"]) - return None + + ```python + @mcp.completion() + async def handle_completion(ref, argument, context): + if isinstance(ref, ResourceTemplateReference): + # Return completions based on ref, argument, and context + return Completion(values=["option1", "option2"]) + return None + ``` """ return self._mcp_server.completion() @@ -610,23 +616,26 @@ def resource( mime_type: Optional MIME type for the resource Example: - @server.resource("resource://my-resource") - def get_data() -> str: - return "Hello, world!" - - @server.resource("resource://my-resource") - async get_data() -> str: - data = await fetch_data() - return f"Hello, world! {data}" - - @server.resource("resource://{city}/weather") - def get_weather(city: str) -> str: - return f"Weather for {city}" - - @server.resource("resource://{city}/weather") - async def get_weather(city: str) -> str: - data = await fetch_weather(city) - return f"Weather for {city}: {data}" + + ```python + @server.resource("resource://my-resource") + def get_data() -> str: + return "Hello, world!" + + @server.resource("resource://my-resource") + async get_data() -> str: + data = await fetch_data() + return f"Hello, world! {data}" + + @server.resource("resource://{city}/weather") + def get_weather(city: str) -> str: + return f"Weather for {city}" + + @server.resource("resource://{city}/weather") + async def get_weather(city: str) -> str: + data = await fetch_weather(city) + return f"Weather for {city}: {data}" + ``` """ # Check if user passed function directly instead of calling decorator if callable(uri): @@ -692,32 +701,35 @@ def prompt( title: Optional human-readable title for the prompt description: Optional description of what the prompt does - Example: - @server.prompt() - def analyze_table(table_name: str) -> list[Message]: - schema = read_table_schema(table_name) - return [ - { - "role": "user", - "content": f"Analyze this schema:\n{schema}" - } - ] + Examples: - @server.prompt() - async def analyze_file(path: str) -> list[Message]: - content = await read_file(path) - return [ - { - "role": "user", - "content": { - "type": "resource", - "resource": { - "uri": f"file://{path}", - "text": content - } + ```python + @server.prompt() + def analyze_table(table_name: str) -> list[Message]: + schema = read_table_schema(table_name) + return [ + { + "role": "user", + "content": f"Analyze this schema: {schema}" + } + ] + + @server.prompt() + async def analyze_file(path: str) -> list[Message]: + content = await read_file(path) + return [ + { + "role": "user", + "content": { + "type": "resource", + "resource": { + "uri": f"file://{path}", + "text": content } } - ] + } + ] + ``` """ # Check if user passed function directly instead of calling decorator if callable(name): @@ -756,9 +768,12 @@ def custom_route( include_in_schema: Whether to include in OpenAPI schema, defaults to True Example: - @server.custom_route("/health", methods=["GET"]) - async def health_check(request: Request) -> Response: - return JSONResponse({"status": "ok"}) + + ```python + @server.custom_route("/health", methods=["GET"]) + async def health_check(request: Request) -> Response: + return JSONResponse({"status": "ok"}) + ``` """ def decorator( diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index 5c696b136..0c6b61938 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -6,31 +6,30 @@ used in MCP servers to interact with the client. Common usage pattern: -``` - server = Server(name) - - @server.call_tool() - async def handle_tool_call(ctx: RequestContext, arguments: dict[str, Any]) -> Any: - # Check client capabilities before proceeding - if ctx.session.check_client_capability( - types.ClientCapabilities(experimental={"advanced_tools": dict()}) - ): - # Perform advanced tool operations - result = await perform_advanced_tool_operation(arguments) - else: - # Fall back to basic tool operations - result = await perform_basic_tool_operation(arguments) - - return result - - @server.list_prompts() - async def handle_list_prompts(ctx: RequestContext) -> list[types.Prompt]: - # Access session for any necessary checks or operations - if ctx.session.client_params: - # Customize prompts based on client initialization parameters - return generate_custom_prompts(ctx.session.client_params) - else: - return default_prompts +```python +server = Server(name) + +@server.call_tool() +async def handle_tool_call(ctx: RequestContext, arguments: dict[str, Any]) -> Any: + # Check client capabilities before proceeding + if ctx.session.check_client_capability( + types.ClientCapabilities(experimental={"advanced_tools": dict()}) + ): + # Perform advanced tool operations + result = await perform_advanced_tool_operation(arguments) + else: + # Fall back to basic tool operations + result = await perform_basic_tool_operation(arguments) + return result + +@server.list_prompts() +async def handle_list_prompts(ctx: RequestContext) -> list[types.Prompt]: + # Access session for any necessary checks or operations + if ctx.session.client_params: + # Customize prompts based on client initialization parameters + return generate_custom_prompts(ctx.session.client_params) + else: + return default_prompts ``` The ServerSession class is typically used internally by the Server class and should not @@ -221,7 +220,86 @@ async def create_message( model_preferences: types.ModelPreferences | None = None, related_request_id: types.RequestId | None = None, ) -> types.CreateMessageResult: - """Send a sampling/create_message request.""" + """Send a message to an LLM through the MCP client for processing. + + This method enables MCP servers to request LLM sampling from the connected client. + The client forwards the request to its configured LLM provider (OpenAI, Anthropic, etc.) + and returns the generated response. This is useful for tools that need LLM assistance + to process user requests or generate content. + + The client must support the sampling capability for this method to work. Check + client capabilities using [`check_client_capability`][mcp.server.session.ServerSession.check_client_capability] before calling this method. + + Args: + messages: List of [`SamplingMessage`][mcp.types.SamplingMessage] objects representing the conversation history. + Each message has a role ("user" or "assistant") and content (text, image, or audio). + max_tokens: Maximum number of tokens the LLM should generate in the response. + system_prompt: Optional system message to set the LLM's behavior and context. + include_context: Optional context inclusion preferences for the LLM request. + temperature: Optional sampling temperature (0.0-1.0) controlling response randomness. + Lower values make responses more deterministic. + stop_sequences: Optional list of strings that will cause the LLM to stop generating + when encountered in the response. + metadata: Optional arbitrary metadata to include with the request. + model_preferences: Optional preferences for which model the client should use. + related_request_id: Optional ID linking this request to a parent request for tracing. + + Returns: + CreateMessageResult containing the LLM's response with role, content, model name, + and stop reason information. + + Raises: + RuntimeError: If called before session initialization is complete. + Various exceptions: Depending on client implementation and LLM provider errors. + + Examples: + Basic text generation: + + ```python + from mcp.types import SamplingMessage, TextContent + + result = await session.create_message( + messages=[ + SamplingMessage( + role="user", + content=TextContent(type="text", text="Explain quantum computing") + ) + ], + max_tokens=150 + ) + print(result.content.text) # Generated explanation + ``` + + Multi-turn conversation with system prompt: + + ```python + from mcp.types import SamplingMessage, TextContent + + result = await session.create_message( + messages=[ + SamplingMessage( + role="user", + content=TextContent(type="text", text="What's the weather like?") + ), + SamplingMessage( + role="assistant", + content=TextContent(type="text", text="I don't have access to weather data.") + ), + SamplingMessage( + role="user", + content=TextContent(type="text", text="Then help me write a poem about rain") + ) + ], + max_tokens=100, + system_prompt="You are a helpful poetry assistant.", + temperature=0.8 + ) + ``` + + Note: + This method requires the client to have sampling capability enabled. Most modern + MCP clients support this, but always check capabilities before use in production code. + """ return await self.send_request( request=types.ServerRequest( types.CreateMessageRequest( diff --git a/src/mcp/types.py b/src/mcp/types.py index 98fefa080..51bd9c91c 100644 --- a/src/mcp/types.py +++ b/src/mcp/types.py @@ -715,7 +715,90 @@ class AudioContent(BaseModel): class SamplingMessage(BaseModel): - """Describes a message issued to or received from an LLM API.""" + """Represents a message in an LLM conversation for sampling/generation requests. + + SamplingMessage is used to structure conversation history when requesting LLM + text generation through the MCP sampling protocol. Each message represents a + single turn in the conversation with a specific role and content. + + This class is primarily used with [`ServerSession.create_message`][mcp.server.session.ServerSession.create_message] to send + conversation context to LLMs via MCP clients. The message format follows + standard LLM conversation patterns with distinct roles for users and assistants. + + Attributes: + role: The speaker role, either "user" for human input or "assistant" for AI responses. + content: The message content, which can be [`TextContent`][mcp.types.TextContent], + [`ImageContent`][mcp.types.ImageContent], or [`AudioContent`][mcp.types.AudioContent]. + + Examples: + Creating a simple text message: + + ```python + from mcp.types import SamplingMessage, TextContent + + user_msg = SamplingMessage( + role="user", + content=TextContent(type="text", text="Hello, how are you?") + ) + ``` + + Creating an assistant response: + + ```python + assistant_msg = SamplingMessage( + role="assistant", + content=TextContent(type="text", text="I'm doing well, thank you!") + ) + ``` + + Creating a message with image content: + + ```python + import base64 + + # Assuming you have image_bytes containing image data + image_data = base64.b64encode(image_bytes).decode() + + image_msg = SamplingMessage( + role="user", + content=ImageContent( + type="image", + data=image_data, + mimeType="image/jpeg" + ) + ) + ``` + + Building a conversation history: + + ```python + conversation = [ + SamplingMessage( + role="user", + content=TextContent(type="text", text="What's 2+2?") + ), + SamplingMessage( + role="assistant", + content=TextContent(type="text", text="2+2 equals 4.") + ), + SamplingMessage( + role="user", + content=TextContent(type="text", text="Now what's 4+4?") + ) + ] + + # Use in create_message call + result = await session.create_message( + messages=conversation, + max_tokens=50 + ) + ``` + + Note: + The role field is constrained to "user" or "assistant" only. The content + supports multiple media types, but actual support depends on the LLM provider + and client implementation. + """ role: Role content: TextContent | ImageContent | AudioContent From 74040f1be80699e45d9295b3885957dbf721ad62 Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Wed, 20 Aug 2025 13:56:16 -0700 Subject: [PATCH 09/11] Enhance SDK docstrings and documentation (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * misc doc work * Enhance SDK docstrings for better new user experience Add comprehensive documentation for key MCP SDK methods and request context: - send_log_message: Access patterns, logging levels, examples - check_client_capability: Capability checking with real examples - elicit methods: Interactive data collection documentation - Request context ecosystem: RequestContext, Context, access patterns - Cross-references and practical examples throughout All changes follow Google Python Style Guide and include verified code examples. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --------- Co-authored-by: Claude --- CLAUDE.md | 45 ++- docs/examples-authentication.md | 108 +++---- docs/examples-clients.md | 4 +- docs/examples-echo-servers.md | 23 +- docs/examples-lowlevel-servers.md | 52 +-- docs/examples-quickstart.md | 26 +- docs/examples-server-advanced.md | 18 +- docs/examples-server-prompts.md | 18 +- docs/examples-server-resources.md | 35 +- docs/examples-structured-output.md | 24 +- docs/examples-transport-http.md | 24 +- mkdocs.yml | 3 +- src/mcp/server/fastmcp/server.py | 496 +++++++++++++++++++++++++---- src/mcp/server/lowlevel/server.py | 196 ++++++++---- src/mcp/server/session.py | 279 +++++++++++++++- src/mcp/shared/context.py | 82 ++++- 16 files changed, 1140 insertions(+), 293 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 77e2829b2..09bcc7f02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,6 +133,45 @@ This document contains critical information about working with this codebase. Fo - Top-level handlers that must not crash - Cleanup blocks (log at debug level) -- Always use sentence case for all headings and heading-like text in any Markdown-formatted content, including docstrings. -- Example snippets in docsstrings MUST only appear within the Examples section of the docstring. You MAY include multiple examples in the Examples section. -- Surround all lists, both ordered and unordered, with blank lines. Applies to Markdown in Markdown files as well as docstrings in Python files. \ No newline at end of file +## Docstring best practices for SDK documentation + +The following guidance ensures docstrings are genuinely helpful for new SDK users by providing navigation, context, and accurate examples. + +### Structure and formatting + +- Follow Google Python Style Guide for docstrings +- Format docstrings in Markdown compatible with mkdocs-material and mkdocstrings +- Always surround lists with blank lines (before and after) - also applies to Markdown (.md) files +- Always surround headings with blank lines - also applies to Markdown (.md) files +- Always surround fenced code blocks with blank lines - also applies to Markdown (.md) files +- Use sentence case for all headings and heading-like text - also applies to Markdown (.md) files + +### Content requirements + +- Access patterns: Explicitly state how users typically access the method/class with phrases like "You typically access this +method through..." or "You typically call this method by..." +- Cross-references: Use extensive cross-references to related members to help SDK users navigate: + - Format: [`displayed_text`][module.path.to.Member] + - Include backticks around the displayed text + - Link to types, related methods, and alternative approaches +- Parameter descriptions: + - Document all valid values for enums/literals + - Explain what each parameter does and when to use it + - Cross-reference parameter types where helpful +- Real-world examples: + - Show actual usage patterns from the SDK, not theoretical code + - Include imports and proper module paths + - Verify examples against source code for accuracy + - Show multiple approaches (e.g., low-level SDK vs FastMCP) + - Add comments explaining what's happening + - Examples should be concise and only as complex as needed to clearly demonstrate real-world usage +- Context and purpose: + - Explain not just what the method does, but why and when to use it + - Include notes about important considerations (e.g., client filtering, performance) + - Mention alternative approaches where applicable + +### Verification + + - All code examples MUST be 100% accurate to the actual SDK implementation + - Verify imports, class names, method signatures against source code + - You MUST NOT rely on existing documentation as authoritative - you MUST check the source diff --git a/docs/examples-authentication.md b/docs/examples-authentication.md index dae0d5ed2..509d3d7cf 100644 --- a/docs/examples-authentication.md +++ b/docs/examples-authentication.md @@ -2,6 +2,59 @@ MCP supports OAuth 2.1 authentication for protecting server resources. This section demonstrates both server-side token verification and client-side authentication flows. +## Security considerations + +When implementing authentication: + +- **Use HTTPS**: All OAuth flows must use HTTPS in production +- **Token validation**: Always validate tokens on the resource server side +- **Scope checking**: Verify that tokens have required scopes +- **Introspection**: Use token introspection for distributed validation +- **RFC compliance**: Follow RFC 9728 for proper authoriazation server (AS) discovery + +## OAuth architecture + +The MCP OAuth implementation follows the OAuth 2.1 authorization code flow with token introspection: + +```mermaid +sequenceDiagram + participant C as Client + participant AS as Authorization Server + participant RS as Resource Server
(MCP Server) + participant U as User + + Note over C,RS: 1. Discovery Phase (RFC 9728) + C->>RS: GET /.well-known/oauth-protected-resource + RS->>C: Protected Resource Metadata
(issuer, scopes, etc.) + + Note over C,AS: 2. Authorization Phase + C->>AS: GET /authorize?response_type=code&client_id=... + AS->>U: Redirect to login/consent + U->>AS: User authenticates and consents + AS->>C: Authorization code (via redirect) + + Note over C,AS: 3. Token Exchange + C->>AS: POST /token
(authorization_code grant) + AS->>C: Access token + refresh token + + Note over C,RS: 4. Resource Access + C->>RS: MCP request + Authorization: Bearer + RS->>AS: POST /introspect
(validate token) + AS->>RS: Token info (active, scopes, user) + RS->>C: MCP response (if authorized) + + Note over C,AS: 5. Token Refresh (when needed) + C->>AS: POST /token
(refresh_token grant) + AS->>C: New access token +``` + +**Components:** + +- **Authorization Server (AS)**: Handles OAuth flows, issues and validates tokens +- **Resource Server (RS)**: Your MCP server that validates tokens and serves protected resources +- **Client**: Discovers AS through RFC 9728, obtains tokens, and uses them with MCP server +- **User**: Resource owner who authorizes access + ## OAuth server implementation FastMCP server with OAuth token verification: @@ -89,58 +142,3 @@ This example shows: - Support for non-RFC 9728 compliant clients - Legacy endpoint compatibility - Migration patterns for existing systems - -## OAuth architecture - -The MCP OAuth implementation follows the OAuth 2.1 authorization code flow with token introspection: - -```mermaid -sequenceDiagram - participant C as Client - participant AS as Authorization Server - participant RS as Resource Server
(MCP Server) - participant U as User - - Note over C,RS: 1. Discovery Phase (RFC 9728) - C->>RS: GET /.well-known/oauth-protected-resource - RS->>C: Protected Resource Metadata
(issuer, scopes, etc.) - - Note over C,AS: 2. Authorization Phase - C->>AS: GET /authorize?response_type=code&client_id=... - AS->>U: Redirect to login/consent - U->>AS: User authenticates and consents - AS->>C: Authorization code (via redirect) - - Note over C,AS: 3. Token Exchange - C->>AS: POST /token
(authorization_code grant) - AS->>C: Access token + refresh token - - Note over C,RS: 4. Resource Access - C->>RS: MCP request + Authorization: Bearer - RS->>AS: POST /introspect
(validate token) - AS->>RS: Token info (active, scopes, user) - RS->>C: MCP response (if authorized) - - Note over C,AS: 5. Token Refresh (when needed) - C->>AS: POST /token
(refresh_token grant) - AS->>C: New access token -``` - -**Components:** - -- **Authorization Server (AS)**: Handles OAuth flows, issues and validates tokens -- **Resource Server (RS)**: Your MCP server that validates tokens and serves protected resources -- **Client**: Discovers AS through RFC 9728, obtains tokens, and uses them with MCP server -- **User**: Resource owner who authorizes access - -## Security considerations - -When implementing authentication: - -1. **Use HTTPS**: All OAuth flows must use HTTPS in production -2. **Token validation**: Always validate tokens on the resource server side -3. **Scope checking**: Verify that tokens have required scopes -4. **Introspection**: Use token introspection for distributed validation -5. **RFC compliance**: Follow RFC 9728 for proper AS discovery - -These examples provide a complete OAuth 2.1 implementation suitable for production use with proper security practices. \ No newline at end of file diff --git a/docs/examples-clients.md b/docs/examples-clients.md index d59b31818..5fa7a2992 100644 --- a/docs/examples-clients.md +++ b/docs/examples-clients.md @@ -2,6 +2,8 @@ MCP clients connect to servers to access tools, resources, and prompts. This section demonstrates various client patterns and connection types. +These examples provide comprehensive patterns for building MCP clients that can handle various server types, authentication methods, and interaction patterns. + ## Basic stdio client Connecting to MCP servers over stdio transport: @@ -123,5 +125,3 @@ This example demonstrates: - Token management and refresh - Protected resource access - Integration with authenticated MCP servers - -These examples provide comprehensive patterns for building MCP clients that can handle various server types, authentication methods, and interaction patterns. \ No newline at end of file diff --git a/docs/examples-echo-servers.md b/docs/examples-echo-servers.md index 1d1b89bb9..0da3d1a41 100644 --- a/docs/examples-echo-servers.md +++ b/docs/examples-echo-servers.md @@ -1,6 +1,15 @@ # Echo server examples -Echo servers are simple examples that demonstrate basic MCP functionality by echoing input back to clients. These are useful for testing and understanding MCP fundamentals. +Echo servers provide a foundation for understanding MCP patterns before building more complex functionality. + +Echo servers are useful for: + +- **Testing client connections**: Verify that your client can connect and call tools +- **Understanding MCP basics**: Learn the fundamental request/response patterns +- **Development and debugging**: Simple, predictable behavior for testing +- **Protocol verification**: Ensure transport layers work correctly + +The following servers are minimal examples that demonstrate basic MCP functionality by echoing input back to clients. ## Simple echo server @@ -30,14 +39,7 @@ This enhanced version demonstrates: - Different parameter types and patterns - Tool naming and description best practices -Echo servers are useful for: - -- **Testing client connections**: Verify that your client can connect and call tools -- **Understanding MCP basics**: Learn the fundamental request/response patterns -- **Development and debugging**: Simple, predictable behavior for testing -- **Protocol verification**: Ensure transport layers work correctly - -## Usage patterns +## Usage These echo servers can be used to test different aspects of MCP: @@ -66,10 +68,9 @@ Example tool calls you can make to echo servers: ``` Expected response: + ```json { "result": "Echo: Hello, MCP!" } ``` - -Echo servers provide a foundation for understanding MCP patterns before building more complex functionality. \ No newline at end of file diff --git a/docs/examples-lowlevel-servers.md b/docs/examples-lowlevel-servers.md index 0643246fc..5e65fb48f 100644 --- a/docs/examples-lowlevel-servers.md +++ b/docs/examples-lowlevel-servers.md @@ -1,6 +1,30 @@ # Low-level server examples -The low-level server API provides maximum control over MCP protocol implementation. Use these patterns when you need fine-grained control or when FastMCP doesn't meet your requirements. +The [low-level server API](/python-sdk/reference/mcp/server/lowlevel/server/) provides maximum control over MCP protocol implementation. Use these patterns when you need fine-grained control or when [`FastMCP`][mcp.server.fastmcp.FastMCP] doesn't meet your requirements. + +The low-level API provides the foundation that FastMCP is built upon, giving you access to all MCP protocol features with complete control over implementation details. + +## When to use low-level API + +Choose the low-level API when you need: + +- Custom protocol message handling +- Complex initialization sequences +- Fine-grained control over capabilities +- Integration with existing server infrastructure +- Performance optimization at the protocol level +- Custom authentication or authorization logic + +Key differences between the low-level server API and FastMCP are: + +| | Low-level API | FastMCP | +| --------------- | ------------------------ | ----------------------------- | +| **Control** | Maximum control | Convention over configuration | +| **Boilerplate** | More verbose | Minimal setup | +| **Decorators** | Server method decorators | Simple function decorators | +| **Schema** | Manual definition | Automatic from type hints | +| **Lifecycle** | Manual management | Automatic handling | +| **Best for** | Complex custom logic | Rapid development | ## Basic low-level server @@ -12,7 +36,7 @@ Fundamental low-level server patterns: This example demonstrates: -- Creating a `Server` instance directly +- Creating a [`Server`][mcp.server.lowlevel.Server] instance directly - Manual handler registration with decorators - Prompt management with `@server.list_prompts()` and `@server.get_prompt()` - Manual capability declaration @@ -69,27 +93,3 @@ This production-ready example includes: - Input validation and error handling - Proper MCP protocol compliance - Tool execution with structured responses - -## Key differences from FastMCP - -| Aspect | Low-level API | FastMCP | -|--------|---------------|---------| -| **Control** | Maximum control | Convention over configuration | -| **Boilerplate** | More verbose | Minimal setup | -| **Decorators** | Server method decorators | Simple function decorators | -| **Schema** | Manual definition | Automatic from type hints | -| **Lifecycle** | Manual management | Automatic handling | -| **Best for** | Complex custom logic | Rapid development | - -## When to use low-level API - -Choose the low-level API when you need: - -- Custom protocol message handling -- Complex initialization sequences -- Fine-grained control over capabilities -- Integration with existing server infrastructure -- Performance optimization at the protocol level -- Custom authentication or authorization logic - -The low-level API provides the foundation that FastMCP is built upon, giving you access to all MCP protocol features with complete control over implementation details. \ No newline at end of file diff --git a/docs/examples-quickstart.md b/docs/examples-quickstart.md index f0e89905d..20d76ae08 100644 --- a/docs/examples-quickstart.md +++ b/docs/examples-quickstart.md @@ -2,9 +2,21 @@ This section provides quick and simple examples to get you started with the MCP Python SDK. +These examples can be run directly with: + +```bash +python server.py +``` + +Or test with the MCP Inspector: + +```bash +uv run mcp dev server.py +``` + ## FastMCP quickstart -The simplest way to create an MCP server is with FastMCP. This example demonstrates the core concepts: tools, resources, and prompts. +The easiest way to create an MCP server is with [`FastMCP`][mcp.server.fastmcp.FastMCP]. This example demonstrates the core concepts: tools, resources, and prompts. ```python --8<-- "examples/snippets/servers/fastmcp_quickstart.py" @@ -38,15 +50,3 @@ This example demonstrates: - Minimal server setup with just a greeting tool - Direct execution without additional configuration - Entry point setup for standalone running - -All these examples can be run directly with: - -```bash -python server.py -``` - -Or tested with the MCP Inspector: - -```bash -uv run mcp dev server.py -``` \ No newline at end of file diff --git a/docs/examples-server-advanced.md b/docs/examples-server-advanced.md index 256892e3f..5c7912c2d 100644 --- a/docs/examples-server-advanced.md +++ b/docs/examples-server-advanced.md @@ -2,6 +2,8 @@ This section covers advanced server patterns including lifecycle management, context handling, and interactive capabilities. +These advanced patterns enable rich, interactive server implementations that go beyond simple request-response workflows. + ## Lifespan management Managing server lifecycle with resource initialization and cleanup: @@ -15,7 +17,7 @@ This example demonstrates: - Type-safe lifespan context management - Resource initialization on startup (database connections, etc.) - Automatic cleanup on shutdown -- Accessing lifespan context from tools via `ctx.request_context.lifespan_context` +- Accessing lifespan context from tools via [`ctx.request_context.lifespan_context`][mcp.server.fastmcp.Context.request_context] ## User interaction and elicitation @@ -27,7 +29,7 @@ Tools that can request additional information from users: This example shows: -- Using `ctx.elicit()` to request user input +- Using [`ctx.elicit()`][mcp.server.fastmcp.Context.elicit] to request user input - Pydantic schemas for validating user responses - Handling user acceptance, decline, or cancellation - Interactive booking workflow patterns @@ -42,8 +44,8 @@ Tools that interact with LLMs through sampling: This demonstrates: -- Using `ctx.session.create_message()` for LLM interaction -- Structured message creation with `SamplingMessage` and `TextContent` +- Using [`ctx.session.create_message()`][mcp.server.session.ServerSession.create_message] for LLM interaction +- Structured message creation with [`SamplingMessage`][mcp.types.SamplingMessage] and [`TextContent`][mcp.types.TextContent] - Processing LLM responses within tools - Chaining LLM interactions for complex workflows @@ -58,7 +60,7 @@ Advanced logging and client notification patterns: This example covers: - Multiple log levels (debug, info, warning, error) -- Resource change notifications via `ctx.session.send_resource_list_changed()` +- Resource change notifications via [`ctx.session.send_resource_list_changed()`][mcp.server.session.ServerSession.send_resource_list_changed] - Contextual logging within tool execution - Client communication patterns @@ -72,8 +74,8 @@ Working with images in MCP servers: This shows: -- Using FastMCP's `Image` class for automatic image handling -- PIL integration for image processing +- Using FastMCP's [`Image`][mcp.server.fastmcp.Image] class for automatic image handling +- PIL integration for image processing with [`PIL.Image.open()`][PIL.Image.open] - Returning images from tools - Image format conversion and optimization @@ -91,5 +93,3 @@ This advanced pattern demonstrates: - Context-aware suggestions (repository suggestions based on owner) - Resource template parameter completion - Prompt argument completion - -These advanced patterns enable rich, interactive server implementations that go beyond simple request-response workflows. \ No newline at end of file diff --git a/docs/examples-server-prompts.md b/docs/examples-server-prompts.md index beb72ae32..b9d7f20e4 100644 --- a/docs/examples-server-prompts.md +++ b/docs/examples-server-prompts.md @@ -2,6 +2,15 @@ Prompts are reusable templates that help structure LLM interactions. They provide a way to define consistent interaction patterns that users can invoke. +Prompts are user-controlled primitives and are particularly useful for: + +- Code review templates +- Debugging assistance workflows +- Content generation patterns +- Structured analysis requests + +Unlike tools (which are model-controlled) and resources (which are application-controlled), prompts are invoked directly by users to initiate specific types of interactions with the LLM. + ## Basic prompts Simple prompt templates for common scenarios: @@ -31,12 +40,3 @@ This low-level server example shows: - Argument handling and validation - Dynamic prompt generation based on parameters - Production-ready prompt patterns using the low-level API - -Prompts are user-controlled primitives that help create consistent, reusable interaction patterns. They're particularly useful for: - -- Code review templates -- Debugging assistance workflows -- Content generation patterns -- Structured analysis requests - -Unlike tools (which are model-controlled) and resources (which are application-controlled), prompts are invoked directly by users to initiate specific types of interactions with the LLM. \ No newline at end of file diff --git a/docs/examples-server-resources.md b/docs/examples-server-resources.md index 8a736f67b..b3a5cfa33 100644 --- a/docs/examples-server-resources.md +++ b/docs/examples-server-resources.md @@ -2,6 +2,8 @@ Resources provide data to LLMs without side effects. They're similar to GET endpoints in REST APIs and should be used for exposing information rather than performing actions. +Resources are essential for providing contextual information to LLMs, whether it's configuration data, file contents, or dynamic information that changes over time. + ## Basic resources Simple resource patterns for exposing data: @@ -17,21 +19,6 @@ This example demonstrates: - Simple string data return - JSON configuration data -## Memory and state management - -Resources that manage server memory and state: - -```python ---8<-- "examples/fastmcp/memory.py" -``` - -This example shows how to: - -- Implement persistent memory across requests -- Store and retrieve conversational context -- Handle memory cleanup and management -- Provide memory resources to LLMs - ## Simple resource server A complete server focused on resource management: @@ -40,11 +27,25 @@ A complete server focused on resource management: --8<-- "examples/servers/simple-resource/mcp_simple_resource/server.py" ``` -This is a full example of a low-level server that: +This is an example of a low-level server that: - Uses the low-level server API for maximum control - Implements resource listing and reading - Handles URI templates and parameter extraction - Demonstrates production-ready resource patterns -Resources are essential for providing contextual information to LLMs, whether it's configuration data, file contents, or dynamic information that changes over time. \ No newline at end of file + +## Memory and state management + +Resources that manage server memory and state: + +```python +--8<-- "examples/fastmcp/memory.py" +``` + +This example shows how to: + +- Implement persistent memory across requests +- Store and retrieve conversational context +- Handle memory cleanup and management +- Provide memory resources to LLMs diff --git a/docs/examples-structured-output.md b/docs/examples-structured-output.md index 04c2b0326..55bf27c10 100644 --- a/docs/examples-structured-output.md +++ b/docs/examples-structured-output.md @@ -2,6 +2,16 @@ Structured output allows tools to return well-typed, validated data that clients can easily process. This section covers various approaches to structured data. +Structured output provides several advantages: + +- **Type Safety**: Automatic validation ensures data integrity +- **Documentation**: Schemas serve as API documentation +- **Client Integration**: Easier processing by client applications +- **Backward Compatibility**: Still provides unstructured text content +- **IDE Support**: Better development experience with type hints + +Choose structured output when you need reliable, processable data from your tools. + ## FastMCP structured output Using FastMCP's automatic structured output capabilities: @@ -13,7 +23,7 @@ Using FastMCP's automatic structured output capabilities: This comprehensive example demonstrates: - **Pydantic models**: Rich validation and documentation (`WeatherData`) -- **TypedDict**: Simpler structures (`LocationInfo`) +- **TypedDict**: Simpler structures (`LocationInfo`) - **Dictionary types**: Flexible schemas (`dict[str, float]`) - **Regular classes**: With type hints for structured output (`UserProfile`) - **Untyped classes**: Fall back to unstructured output (`UntypedConfig`) @@ -55,15 +65,3 @@ These examples demonstrate: - Validation against defined schemas - Returning structured data directly from tools - Backward compatibility with unstructured content - -## Benefits of structured output - -Structured output provides several advantages: - -1. **Type Safety**: Automatic validation ensures data integrity -2. **Documentation**: Schemas serve as API documentation -3. **Client Integration**: Easier processing by client applications -4. **Backward Compatibility**: Still provides unstructured text content -5. **IDE Support**: Better development experience with type hints - -Choose structured output when you need reliable, processable data from your tools. \ No newline at end of file diff --git a/docs/examples-transport-http.md b/docs/examples-transport-http.md index 06e751e62..54e45640e 100644 --- a/docs/examples-transport-http.md +++ b/docs/examples-transport-http.md @@ -2,6 +2,18 @@ HTTP transports enable web-based MCP server deployment with support for multiple clients and scalable architectures. +Choose HTTP transports for production deployments that need to serve multiple clients or integrate with web infrastructure. + +## Transport comparison + +| Feature | Streamable HTTP | SSE | stdio | +| ---------------- | ------------------ | ----------------- | ---------------- | +| **Resumability** | ✅ With event store | ❌ | ❌ | +| **Scalability** | ✅ Multi-client | ✅ Multi-client | ❌ Single process | +| **State** | Configurable | Session-based | Process-based | +| **Deployment** | Web servers | Web servers | Local execution | +| **Best for** | Production APIs | Real-time updates | Development/CLI | + ## Streamable HTTP configuration Basic streamable HTTP server setup with different configurations: @@ -77,15 +89,3 @@ This component enables: - Event replay for missed messages - Persistent streaming across connection interruptions - Production-ready resumability patterns - -## Transport comparison - -| Feature | Streamable HTTP | SSE | stdio | -|---------|----------------|-----|-------| -| **Resumability** | ✅ With event store | ❌ | ❌ | -| **Scalability** | ✅ Multi-client | ✅ Multi-client | ❌ Single process | -| **State** | Configurable | Session-based | Process-based | -| **Deployment** | Web servers | Web servers | Local execution | -| **Best for** | Production APIs | Real-time updates | Development/CLI | - -Choose HTTP transports for production deployments that need to serve multiple clients or integrate with web infrastructure. \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 9114e8f84..7d03fea64 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -120,7 +120,7 @@ plugins: paths: [src/mcp] options: group_by_category: false - members_order: source + # members_order: source relative_crossrefs: true separate_signature: true show_signature_annotations: true @@ -130,5 +130,6 @@ plugins: - url: https://docs.python.org/3/objects.inv - url: https://docs.pydantic.dev/latest/objects.inv - url: https://typing-extensions.readthedocs.io/en/latest/objects.inv + - url: https://pillow.readthedocs.io/en/stable/objects.inv - api-autonav: modules: ["src/mcp"] diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index e29866bb2..291a01bcf 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -360,21 +360,91 @@ async def list_tools(self) -> list[MCPTool]: ] def get_context(self) -> Context[ServerSession, LifespanResultT, Request]: - """Get the current request context for accessing MCP capabilities. + """Get the current request context when automatic injection isn't available. - The context provides access to logging, progress reporting, resource reading, - user interaction, and request metadata. It's only valid during request - processing - calling context methods outside of a request will raise errors. + This method provides access to the current [`Context`][mcp.server.fastmcp.Context] + object when you can't rely on FastMCP's automatic parameter injection. It's + primarily useful in helper functions, callbacks, or other scenarios where + the context isn't automatically provided via function parameters. + + In most cases, you should prefer automatic context injection by declaring + a Context parameter in your tool/resource functions. Use this method only + when you need context access from code that isn't directly called by FastMCP. + + ## When to use this method + + **Helper functions**: When context is needed in utility functions: + + ```python + mcp = FastMCP(name="example") + + async def log_operation(operation: str): + # Get context when it's not injected + ctx = mcp.get_context() + await ctx.info(f"Performing operation: {operation}") + + @mcp.tool() + async def main_tool(data: str) -> str: + await log_operation("data_processing") # Helper needs context + return process_data(data) + ``` + + **Callbacks and event handlers**: When context is needed in async callbacks: + + ```python + async def progress_callback(current: int, total: int): + ctx = mcp.get_context() # Access context in callback + await ctx.report_progress(current, total) + + @mcp.tool() + async def long_operation(data: str) -> str: + return await process_with_callback(data, progress_callback) + ``` + + **Class methods**: When context is needed in class-based code: + + ```python + class DataProcessor: + def __init__(self, mcp_server: FastMCP): + self.mcp = mcp_server + + async def process_chunk(self, chunk: str) -> str: + ctx = self.mcp.get_context() # Get context in method + await ctx.debug(f"Processing chunk of size {len(chunk)}") + return processed_chunk + + processor = DataProcessor(mcp) + + @mcp.tool() + async def process_data(data: str) -> str: + return await processor.process_chunk(data) + ``` Returns: - Context object for the current request with access to MCP capabilities. + [`Context`][mcp.server.fastmcp.Context] object for the current request + with access to all MCP capabilities including logging, progress reporting, + user interaction, and session access. Raises: - LookupError: If called outside of a request context. + LookupError: If called outside of a request context (e.g., during server + initialization, shutdown, or from code not handling a client request). Note: - This method should typically only be called from within tool, resource, - or prompt handlers where a request context is active. + **Prefer automatic injection**: In most cases, declare a Context parameter + in your function signature instead of calling this method: + + ```python + # Preferred approach + @mcp.tool() + async def my_tool(data: str, ctx: Context) -> str: + await ctx.info("Processing data") + return result + + # Only use get_context() when injection isn't available + async def helper_function(): + ctx = mcp.get_context() + await ctx.info("Helper called") + ``` """ try: request_context = self._mcp_server.request_context @@ -1148,40 +1218,156 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: class Context(BaseModel, Generic[ServerSessionT, LifespanContextT, RequestT]): - """Context object providing access to MCP capabilities. + """High-level context object providing convenient access to MCP capabilities. - This provides a cleaner interface to MCP's RequestContext functionality. - It gets injected into tool and resource functions that request it via type hints. - The context provides access to logging, progress reporting, resource reading, - user interaction, and request metadata. + This is FastMCP's user-friendly wrapper around the underlying [`RequestContext`][mcp.shared.context.RequestContext] + that provides the same functionality with additional convenience methods and better + ergonomics. It gets automatically injected into FastMCP tool and resource functions + that declare it in their type hints, eliminating the need to manually access the + request context. - The context parameter name can be anything as long as it's annotated with Context. - The context is optional - tools that don't need it can omit the parameter. + The Context object provides access to all MCP capabilities including logging, + progress reporting, resource reading, user interaction, capability checking, and + access to the underlying session and request metadata. It's the recommended way + to interact with MCP functionality in FastMCP applications. - Examples: - Using context in a tool function: + ## Automatic injection - ```python - @server.tool() - def my_tool(x: int, ctx: Context) -> str: - # Log messages to the client - ctx.info(f"Processing {x}") - ctx.debug("Debug info") - ctx.warning("Warning message") - ctx.error("Error message") - - # Report progress - ctx.report_progress(50, 100) - - # Access resources - data = ctx.read_resource("resource://data") - - # Get request info - request_id = ctx.request_id - client_id = ctx.client_id - - return str(x) - ``` + Context is automatically injected into functions based on type hints. The parameter + name can be anything as long as it's annotated with `Context`. The context parameter + is optional - tools that don't need it can omit it entirely. + + ```python + from mcp.server.fastmcp import FastMCP, Context + + mcp = FastMCP(name="example") + + @mcp.tool() + async def simple_tool(data: str) -> str: + # No context needed + return f"Processed: {data}" + + @mcp.tool() + async def advanced_tool(data: str, ctx: Context) -> str: + # Context automatically injected + await ctx.info("Starting processing") + return f"Processed: {data}" + ``` + + ## Relationship to RequestContext + + Context is a thin wrapper around [`RequestContext`][mcp.shared.context.RequestContext] + that provides the same underlying functionality with additional convenience methods: + + - **Context convenience methods**: `ctx.info()`, `ctx.error()`, `ctx.elicit()`, etc. + - **Direct RequestContext access**: `ctx.request_context` for low-level operations + - **Session access**: `ctx.session` for advanced ServerSession functionality + - **Request metadata**: `ctx.request_id`, access to lifespan context, etc. + + ## Capabilities provided + + **Logging**: Send structured log messages to the client with automatic request linking: + + ```python + await ctx.debug("Detailed debug information") + await ctx.info("General status updates") + await ctx.warning("Important warnings") + await ctx.error("Error conditions") + ``` + + **Progress reporting**: Keep users informed during long operations: + + ```python + for i in range(100): + await ctx.report_progress(i, 100, f"Processing item {i}") + # ... do work + ``` + + **User interaction**: Collect additional information during tool execution: + + ```python + class UserPrefs(BaseModel): + format: str + detailed: bool + + result = await ctx.elicit("How should I format the output?", UserPrefs) + if result.action == "accept": + format_data(data, result.data.format) + ``` + + **Resource access**: Read MCP resources during tool execution: + + ```python + content = await ctx.read_resource("file://data/config.json") + ``` + + **Capability checking**: Verify client support before using advanced features: + + ```python + if ctx.session.check_client_capability(types.ClientCapabilities(sampling=...)): + # Use advanced features + pass + ``` + + ## Examples + + Complete tool with context usage: + + ```python + from pydantic import BaseModel + from mcp.server.fastmcp import FastMCP, Context + + class ProcessingOptions(BaseModel): + format: str + include_metadata: bool + + mcp = FastMCP(name="processor") + + @mcp.tool() + async def process_data( + data: str, + ctx: Context, + auto_format: bool = False + ) -> str: + await ctx.info(f"Starting to process {len(data)} characters") + + # Get user preferences if not auto-formatting + if not auto_format: + if ctx.session.check_client_capability( + types.ClientCapabilities(elicitation=types.ElicitationCapability()) + ): + prefs_result = await ctx.elicit( + "How would you like the data processed?", + ProcessingOptions + ) + if prefs_result.action == "accept": + format_type = prefs_result.data.format + include_meta = prefs_result.data.include_metadata + else: + await ctx.warning("Using default format") + format_type = "standard" + include_meta = False + else: + format_type = "standard" + include_meta = False + else: + format_type = "auto" + include_meta = True + + # Process with progress updates + for i in range(0, len(data), 100): + chunk = data[i:i+100] + await ctx.report_progress(i, len(data), f"Processing chunk {i//100 + 1}") + # ... process chunk + + await ctx.info(f"Processing complete with format: {format_type}") + return processed_data + ``` + + Note: + Context objects are request-scoped and automatically managed by FastMCP. + Don't store references to them beyond the request lifecycle. Each tool + invocation gets a fresh Context instance tied to that specific request. """ _request_context: RequestContext[ServerSessionT, LifespanContextT, RequestT] | None @@ -1209,7 +1395,36 @@ def fastmcp(self) -> FastMCP: def request_context( self, ) -> RequestContext[ServerSessionT, LifespanContextT, RequestT]: - """Access to the underlying request context.""" + """Access to the underlying RequestContext for low-level operations. + + This property provides direct access to the [`RequestContext`][mcp.shared.context.RequestContext] + that this Context wraps. Use this when you need low-level access to request + metadata, lifespan context, or other features not exposed by Context's + convenience methods. + + Most users should prefer Context's convenience methods like `info()`, `elicit()`, + etc. rather than accessing the underlying RequestContext directly. + + Returns: + The underlying [`RequestContext`][mcp.shared.context.RequestContext] containing + session, metadata, and lifespan context. + + Raises: + ValueError: If called outside of a request context. + + Example: + ```python + @mcp.tool() + async def advanced_tool(data: str, ctx: Context) -> str: + # Access lifespan context directly + db = ctx.request_context.lifespan_context["database"] + + # Access request metadata + progress_token = ctx.request_context.meta.progressToken if ctx.request_context.meta else None + + return processed_data + ``` + """ if self._request_context is None: raise ValueError("Context is not available outside of a request") return self._request_context @@ -1251,26 +1466,132 @@ async def elicit( message: str, schema: type[ElicitSchemaModelT], ) -> ElicitationResult[ElicitSchemaModelT]: - """Elicit information from the client/user. + """Elicit structured information from the client or user during tool execution. + + This method enables interactive data collection from clients during tool processing. + The client may display the message to the user and collect a response according to + the provided Pydantic schema, or if the client is an agent, it may automatically + generate an appropriate response. This is useful for gathering additional parameters, + user preferences, or confirmation before proceeding with operations. - This method can be used to interactively ask for additional information from the - client within a tool's execution. The client might display the message to the - user and collect a response according to the provided schema. Or in case a - client is an agent, it might decide how to handle the elicitation -- either by asking - the user or automatically generating a response. + You typically access this method through the [`Context`][mcp.server.fastmcp.Context] + object injected into your FastMCP tool functions. Always check that the client + supports elicitation using [`check_client_capability`][mcp.server.session.ServerSession.check_client_capability] + before calling this method. Args: - schema: A Pydantic model class defining the expected response structure, according to the specification, - only primive types are allowed. - message: Optional message to present to the user. If not provided, will use - a default message based on the schema + message: The prompt or question to present to the user. Should clearly explain + what information is being requested and why it's needed. + schema: A Pydantic model class defining the expected response structure. + According to the MCP specification, only primitive types (str, int, float, bool) + and simple containers (list, dict) are allowed - no complex nested objects. Returns: - An ElicitationResult containing the action taken and the data if accepted + [`ElicitationResult`][mcp.server.fastmcp.utilities.types.ElicitationResult] containing: + + - `action`: One of "accept", "decline", or "cancel" indicating user response + - `data`: The structured response data (only populated if action is "accept") + + Raises: + RuntimeError: If called before session initialization is complete. + ValidationError: If the client response doesn't match the provided schema. + Various exceptions: Depending on client implementation and user interaction. + + Examples: + Collect user preferences before processing: + + ```python + from pydantic import BaseModel + from mcp.server.fastmcp import FastMCP, Context + + class ProcessingOptions(BaseModel): + format: str + include_metadata: bool + max_items: int + + mcp = FastMCP(name="example-server") + + @mcp.tool() + async def process_data(data: str, ctx: Context) -> str: + # Check if client supports elicitation + if not ctx.session.check_client_capability( + types.ClientCapabilities(elicitation=types.ElicitationCapability()) + ): + # Fall back to default processing + return process_with_defaults(data) + + # Ask user for processing preferences + result = await ctx.elicit( + "How would you like me to process this data?", + ProcessingOptions + ) + + if result.action == "accept": + options = result.data + await ctx.info(f"Processing with format: {options.format}") + return process_with_options(data, options) + elif result.action == "decline": + return process_with_defaults(data) + else: # cancel + return "Processing cancelled by user" + ``` + + Confirm before destructive operations: + + ```python + class ConfirmDelete(BaseModel): + confirm: bool + reason: str + + @mcp.tool() + async def delete_files(pattern: str, ctx: Context) -> str: + files = find_matching_files(pattern) + + result = await ctx.elicit( + f"About to delete {len(files)} files matching '{pattern}'. Continue?", + ConfirmDelete + ) + + if result.action == "accept" and result.data.confirm: + await ctx.info(f"Deletion confirmed: {result.data.reason}") + return delete_files(files) + else: + return "Deletion cancelled" + ``` + + Handle different response types: + + ```python + class UserChoice(BaseModel): + option: str # "auto", "manual", "skip" + details: str + + @mcp.tool() + async def configure_system(ctx: Context) -> str: + result = await ctx.elicit( + "How should I configure the system?", + UserChoice + ) + + match result.action: + case "accept": + choice = result.data + await ctx.info(f"User selected: {choice.option}") + return configure_with_choice(choice) + case "decline": + await ctx.warning("User declined configuration") + return "Configuration skipped by user" + case "cancel": + await ctx.info("Configuration cancelled") + return "Operation cancelled" + ``` Note: - Check the result.action to determine if the user accepted, declined, or cancelled. - The result.data will only be populated if action is "accept" and validation succeeded. + The client determines how to handle elicitation requests. Some clients may + show interactive forms to users, while others may automatically generate + responses based on context. Always handle all possible action values + ("accept", "decline", "cancel") in your code and provide appropriate + fallbacks for clients that don't support elicitation. """ return await elicit_with_validation( @@ -1305,12 +1626,79 @@ def client_id(self) -> str | None: @property def request_id(self) -> str: - """Get the unique ID for this request.""" + """Get the unique identifier for the current request. + + This ID uniquely identifies the current client request and is useful for + logging, tracing, error reporting, and linking related operations. It's + automatically used by Context's convenience methods when sending notifications + or responses to ensure they're associated with the correct request. + + Returns: + str: Unique request identifier that can be used for tracing and logging. + + Example: + ```python + @mcp.tool() + async def traceable_tool(data: str, ctx: Context) -> str: + # Log with request ID for traceability + print(f"Processing request {ctx.request_id}") + + # Request ID is automatically included in Context methods + await ctx.info("Starting processing") # Links to this request + + return processed_data + ``` + """ return str(self.request_context.request_id) @property def session(self): - """Access to the underlying session for advanced usage.""" + """Access to the underlying ServerSession for advanced MCP operations. + + This property provides direct access to the [`ServerSession`][mcp.server.session.ServerSession] + for advanced operations not covered by Context's convenience methods. Use this + when you need direct session control, capability checking, or low-level MCP + protocol operations. + + Most users should prefer Context's convenience methods (`info()`, `elicit()`, etc.) + which internally use this session with appropriate request linking. + + Returns: + [`ServerSession`][mcp.server.session.ServerSession]: The session for + communicating with the client and accessing advanced MCP features. + + Examples: + Capability checking before using advanced features: + + ```python + @mcp.tool() + async def advanced_tool(data: str, ctx: Context) -> str: + # Check client capabilities + if ctx.session.check_client_capability( + types.ClientCapabilities(sampling=types.SamplingCapability()) + ): + # Use LLM sampling + response = await ctx.session.create_message( + messages=[types.SamplingMessage(...)], + max_tokens=100 + ) + return response.content.text + else: + return "Client doesn't support LLM sampling" + ``` + + Direct resource notifications: + + ```python + @mcp.tool() + async def update_resource(uri: str, ctx: Context) -> str: + # ... update the resource ... + + # Notify client of resource changes + await ctx.session.send_resource_updated(AnyUrl(uri)) + return "Resource updated" + ``` + """ return self.request_context.session # Convenience methods for common log levels diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index c8375f63d..2990960b8 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -5,64 +5,83 @@ It allows you to easily define and handle various types of requests and notifications in an asynchronous manner. -Usage: -1. Create a Server instance: - server = Server("your_server_name") - -2. Define request handlers using decorators: - @server.list_prompts() - async def handle_list_prompts() -> list[types.Prompt]: - # Implementation - - @server.get_prompt() - async def handle_get_prompt( - name: str, arguments: dict[str, str] | None - ) -> types.GetPromptResult: - # Implementation - - @server.list_tools() - async def handle_list_tools() -> list[types.Tool]: - # Implementation - - @server.call_tool() - async def handle_call_tool( - name: str, arguments: dict | None - ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: - # Implementation - - @server.list_resource_templates() - async def handle_list_resource_templates() -> list[types.ResourceTemplate]: - # Implementation +The [`Server`][mcp.server.lowlevel.server.Server] class provides methods to register handlers for various MCP requests and +notifications. It automatically manages the request context and handles incoming +messages from the client. + +## Usage example + +1. Create a [`Server`][mcp.server.lowlevel.server.Server] instance: + + ```python + server = Server("your_server_name") + ``` + + 2. Define request handlers using decorators: + + ```python + @server.list_prompts() + async def handle_list_prompts() -> list[types.Prompt]: + # Implementation + ... + + @server.get_prompt() + async def handle_get_prompt( + name: str, arguments: dict[str, str] | None + ) -> types.GetPromptResult: + # Implementation + ... + + @server.list_tools() + async def handle_list_tools() -> list[types.Tool]: + # Implementation + ... + + @server.call_tool() + async def handle_call_tool( + name: str, arguments: dict | None + ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: + # Implementation + ... + + @server.list_resource_templates() + async def handle_list_resource_templates() -> list[types.ResourceTemplate]: + # Implementation + ... + ``` 3. Define notification handlers if needed: - @server.progress_notification() - async def handle_progress( - progress_token: str | int, progress: float, total: float | None, - message: str | None - ) -> None: - # Implementation + + ```python + @server.progress_notification() + async def handle_progress( + progress_token: str | int, progress: float, total: float | None, + message: str | None + ) -> None: + # Implementation + ... + ``` 4. Run the server: - async def main(): - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - InitializationOptions( - server_name="your_server_name", - server_version="your_version", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) - - asyncio.run(main()) - -The Server class provides methods to register handlers for various MCP requests and -notifications. It automatically manages the request context and handles incoming -messages from the client. + + ```python + async def main(): + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name="your_server_name", + server_version="your_version", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) + + asyncio.run(main()) + ``` """ from __future__ import annotations as _annotations @@ -226,7 +245,73 @@ def get_capabilities( def request_context( self, ) -> RequestContext[ServerSession, LifespanResultT, RequestT]: - """If called outside of a request context, this will raise a LookupError.""" + """Access the current request context for low-level MCP server operations. + + This property provides access to the [`RequestContext`][mcp.shared.context.RequestContext] + for the current request, which contains the session, request metadata, lifespan + context, and other request-scoped information. This is the primary way to access + MCP capabilities when using the low-level SDK. + + You typically access this property from within handler functions (tool handlers, + resource handlers, prompt handlers, etc.) to get the context for the current + client request. The context is automatically managed by the server and is only + available during request processing. + + ## Common usage patterns + + **Logging and communication**: + + ```python + @app.call_tool() + async def my_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: + ctx = app.request_context + await ctx.session.send_log_message( + level="info", + data="Starting tool processing", + related_request_id=ctx.request_id + ) + ``` + + **Capability checking**: + + ```python + @app.call_tool() + async def advanced_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: + ctx = app.request_context + if ctx.session.check_client_capability( + types.ClientCapabilities(sampling=types.SamplingCapability()) + ): + # Use advanced features + response = await ctx.session.create_message(messages, max_tokens=100) + else: + # Fall back to basic functionality + pass + ``` + + **Accessing lifespan resources**: + + ```python + @app.call_tool() + async def database_query(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: + ctx = app.request_context + db = ctx.lifespan_context["database"] # Access startup resource + results = await db.query(arguments["sql"]) + return [types.TextContent(type="text", text=str(results))] + ``` + + Returns: + [`RequestContext`][mcp.shared.context.RequestContext] for the current request, + containing session, metadata, and lifespan context. + + Raises: + LookupError: If called outside of a request context (e.g., during server + initialization, shutdown, or from code not handling a client request). + + Note: + For FastMCP applications, consider using the injected [`Context`][mcp.server.fastmcp.Context] + parameter instead, which provides the same functionality with additional + convenience methods and better ergonomics. + """ return request_ctx.get() def list_prompts(self): @@ -430,6 +515,7 @@ def call_tool(self, *, validate_input: bool = True): The handler validates input against inputSchema (if validate_input=True), calls the tool function, and builds a CallToolResult with the results: + - Unstructured content (iterable of ContentBlock): returned in content - Structured content (dict): returned in structuredContent, serialized JSON text returned in content - Both: returned in content and structuredContent diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index 0c6b61938..c51260527 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -102,7 +102,102 @@ def client_params(self) -> types.InitializeRequestParams | None: return self._client_params def check_client_capability(self, capability: types.ClientCapabilities) -> bool: - """Check if the client supports a specific capability.""" + """Check if the client supports specific capabilities before using advanced MCP features. + + This method allows MCP servers to verify that the connected client supports + required capabilities before calling methods that depend on them. It performs + an AND operation - the client must support ALL capabilities specified in the + request, not just some of them. + + You typically access this method through the session available in your request + context via [`app.request_context.session`][mcp.shared.context.RequestContext] + within handler functions. Always check capabilities before using features like + sampling, elicitation, or experimental functionality. + + Args: + capability: A [`types.ClientCapabilities`][mcp.types.ClientCapabilities] object + specifying which capabilities to check. Can include: + + - `roots`: Check if client supports root listing operations + - `sampling`: Check if client supports LLM sampling via [`create_message`][mcp.server.session.ServerSession.create_message] + - `elicitation`: Check if client supports user interaction via [`elicit`][mcp.server.session.ServerSession.elicit] + - `experimental`: Check for non-standard experimental capabilities + + Returns: + bool: `True` if the client supports ALL requested capabilities, `False` if + the client hasn't been initialized yet or lacks any of the requested + capabilities. + + Examples: + Check sampling capability before creating LLM messages: + + ```python + from typing import Any + from mcp.server.lowlevel import Server + import mcp.types as types + + app = Server("example-server") + + @app.call_tool() + async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: + ctx = app.request_context + + # Check if client supports LLM sampling + if ctx.session.check_client_capability( + types.ClientCapabilities(sampling=types.SamplingCapability()) + ): + # Safe to use create_message + response = await ctx.session.create_message( + messages=[types.SamplingMessage( + role="user", + content=types.TextContent(type="text", text="Help me analyze this data") + )], + max_tokens=100 + ) + return [types.TextContent(type="text", text=response.content.text)] + else: + return [types.TextContent(type="text", text="Client doesn't support LLM sampling")] + ``` + + Check experimental capabilities: + + ```python + @app.call_tool() + async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: + ctx = app.request_context + + # Check for experimental advanced tools capability + if ctx.session.check_client_capability( + types.ClientCapabilities(experimental={"advanced_tools": {}}) + ): + # Use experimental features + return await use_advanced_tool_features(arguments) + else: + # Fall back to basic functionality + return await use_basic_tool_features(arguments) + ``` + + Check multiple capabilities at once: + + ```python + # Client must support BOTH sampling AND elicitation + if ctx.session.check_client_capability( + types.ClientCapabilities( + sampling=types.SamplingCapability(), + elicitation=types.ElicitationCapability() + ) + ): + # Safe to use both features + user_input = await ctx.session.elicit("What would you like to analyze?", schema) + llm_response = await ctx.session.create_message(messages, max_tokens=100) + ``` + + Note: + This method returns `False` if the session hasn't been initialized yet + (before the client sends the initialization request). It also returns + `False` if the client lacks ANY of the requested capabilities - all + specified capabilities must be supported for this method to return `True`. + """ if self._client_params is None: return False @@ -181,7 +276,157 @@ async def send_log_message( logger: str | None = None, related_request_id: types.RequestId | None = None, ) -> None: - """Send a log message notification.""" + """Send a log message notification from the server to the client. + + This method allows MCP servers to send log messages to the connected client for + debugging, monitoring, and error reporting purposes. The client can filter these + messages based on the logging level it has configured via the logging/setLevel + request. Check client capabilities using [`check_client_capability`][mcp.server.session.ServerSession.check_client_capability] + if you need to verify logging support. + + You typically access this method through the session available in your request + context. When using the low-level SDK, access it via + [`app.request_context.session`][mcp.shared.context.RequestContext] within handler + functions. With FastMCP, use the convenience logging methods on the + [`Context`][mcp.server.fastmcp.Context] object instead, like + [`ctx.info()`][mcp.server.fastmcp.Context.info] or + [`ctx.error()`][mcp.server.fastmcp.Context.error]. + + Log messages are one-way notifications and do not expect a response from the client. + They are useful for providing visibility into server operations, debugging issues, + and tracking the flow of request processing. + + Args: + level: The severity level of the log message as a `types.LoggingLevel`. Must be one of: + + - `debug`: Detailed information for debugging + - `info`: General informational messages + - `notice`: Normal but significant conditions + - `warning`: Warning conditions that should be addressed + - `error`: Error conditions that don't prevent operation + - `critical`: Critical conditions requiring immediate attention + - `alert`: Action must be taken immediately + - `emergency`: System is unusable + + data: The data to log. Can be any JSON-serializable value including: + + - Simple strings for text messages + - Objects/dictionaries for structured logging + - Lists for multiple related items + - Numbers, booleans, or null values + + logger: Optional name to identify the source of the log message. + Useful for categorizing logs from different components or modules + within your server (e.g., "database", "auth", "tool_handler"). + related_request_id: Optional [`types.RequestId`][mcp.types.RequestId] linking this log to a specific client request. + Use this to associate log messages with the request they relate to, + making it easier to trace request processing and debug issues. + + Returns: + None + + Raises: + RuntimeError: If called before session initialization is complete. + Various exceptions: Depending on serialization or transport errors. + + Examples: + In a tool handler using the low-level SDK: + + ```python + from typing import Any + from mcp.server.lowlevel import Server + import mcp.types as types + + app = Server("example-server") + + @app.call_tool() + async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: + # Access the request context to get the session + ctx = app.request_context + + # Log the start of processing + await ctx.session.send_log_message( + level="info", + data=f"Processing tool call: {name}", + logger="tool_handler", + related_request_id=ctx.request_id + ) + + # Process and log any issues + try: + result = perform_operation(arguments) + except Exception as e: + await ctx.session.send_log_message( + level="error", + data={"error": str(e), "tool": name, "args": arguments}, + logger="tool_handler", + related_request_id=ctx.request_id + ) + raise + + return [types.TextContent(type="text", text=str(result))] + ``` + + Using FastMCP's [`Context`][mcp.server.fastmcp.Context] helper for cleaner logging: + + ```python + from mcp.server.fastmcp import FastMCP, Context + + mcp = FastMCP(name="example-server") + + @mcp.tool() + async def fetch_data(url: str, ctx: Context) -> str: + # FastMCP's Context provides convenience methods that internally + # call send_log_message with the appropriate parameters + await ctx.info(f"Fetching data from {url}") + await ctx.debug("Starting request") + + try: + data = await fetch(url) + await ctx.info("Data fetched successfully") + return data + except Exception as e: + await ctx.error(f"Failed to fetch: {e}") + raise + ``` + + Streaming notifications with progress updates: + + ```python + import anyio + from typing import Any + from mcp.server.lowlevel import Server + import mcp.types as types + + app = Server("example-server") + + @app.call_tool() + async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: + ctx = app.request_context + count = arguments.get("count", 5) + + for i in range(count): + # Send progress updates to the client + await ctx.session.send_log_message( + level="info", + data=f"[{i + 1}/{count}] Processing item", + logger="progress_stream", + related_request_id=ctx.request_id + ) + if i < count - 1: + await anyio.sleep(1) + + return [types.TextContent(type="text", text="Operation complete")] + ``` + + Note: + Log messages are only delivered to the client if the client's configured + logging level permits it. For example, if the client has set its level to + "warning", it will not receive "debug" or "info" messages. Consider this + when deciding what level to use for your log messages. This method internally + uses [`send_notification`][mcp.shared.session.BaseSession.send_notification] to + deliver the log message to the client. + """ await self.send_notification( types.ServerNotification( types.LoggingMessageNotification( @@ -339,14 +584,36 @@ async def elicit( requestedSchema: types.ElicitRequestedSchema, related_request_id: types.RequestId | None = None, ) -> types.ElicitResult: - """Send an elicitation/create request. + """Send an elicitation request to collect structured information from the client. + + This is the low-level method for client elicitation. For most use cases, prefer + the higher-level [`Context.elicit`][mcp.server.fastmcp.Context.elicit] method + which provides automatic Pydantic validation and a more convenient interface. + + You typically access this method through the session available in your request + context via [`app.request_context.session`][mcp.shared.context.RequestContext] + within handler functions. Always check that the client supports elicitation using + [`check_client_capability`][mcp.server.session.ServerSession.check_client_capability] + before calling this method. Args: - message: The message to present to the user - requestedSchema: Schema defining the expected response structure + message: The prompt or question to present to the user. + requestedSchema: A [`types.ElicitRequestedSchema`][mcp.types.ElicitRequestedSchema] + defining the expected response structure according to JSON Schema. + related_request_id: Optional [`types.RequestId`][mcp.types.RequestId] linking + this elicitation to a specific client request for tracing. Returns: - The client's response + [`types.ElicitResult`][mcp.types.ElicitResult] containing the client's response + and action taken (accept, decline, or cancel). + + Raises: + RuntimeError: If called before session initialization is complete. + Various exceptions: Depending on client implementation and user interaction. + + Note: + Most developers should use [`Context.elicit`][mcp.server.fastmcp.Context.elicit] + instead, which provides Pydantic model validation and better error handling. """ return await self.send_request( types.ServerRequest( diff --git a/src/mcp/shared/context.py b/src/mcp/shared/context.py index 483681fdd..68c0152cb 100644 --- a/src/mcp/shared/context.py +++ b/src/mcp/shared/context.py @@ -15,15 +15,83 @@ class RequestContext(Generic[SessionT, LifespanContextT, RequestT]): """Context object containing information about the current MCP request. - This context is available during request processing and provides access - to the request metadata, session, and any lifespan-scoped resources. + This is the fundamental context object in the MCP Python SDK that provides access + to request-scoped information and capabilities. It's created automatically for each + incoming client request and contains everything needed to process that request, + including the session for client communication, request metadata, and any resources + initialized during server startup. + + The RequestContext is available throughout the request lifecycle and provides the + foundation for both low-level and high-level SDK usage patterns. In the low-level + SDK, you access it via [`Server.request_context`][mcp.server.lowlevel.server.Server.request_context]. + In FastMCP, it's wrapped by the more convenient [`Context`][mcp.server.fastmcp.Context] + class that provides the same functionality with additional helper methods. + + ## Request lifecycle + + The RequestContext is created when a client request arrives and destroyed when the + request completes. It's only available during request processing - attempting to + access it outside of a request handler will raise a `LookupError`. + + ## Access patterns + + **Low-level SDK**: Access directly via the server's request_context property: + + ```python + @app.call_tool() + async def my_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: + ctx = app.request_context # Get the RequestContext + await ctx.session.send_log_message(level="info", data="Processing...") + ``` + + **FastMCP**: Use the injected Context wrapper instead: + + ```python + @mcp.tool() + async def my_tool(data: str, ctx: Context) -> str: + await ctx.info("Processing...") # Context provides convenience methods + ``` + + ## Lifespan context integration + + Resources initialized during server startup (databases, connections, etc.) are + accessible through the `lifespan_context` attribute, enabling request handlers + to use shared resources safely: + + ```python + # Server startup - initialize shared resources + @asynccontextmanager + async def server_lifespan(server): + db = await Database.connect() + try: + yield {"db": db} + finally: + await db.disconnect() + + # Request handling - access shared resources + @server.call_tool() + async def query_data(name: str, arguments: dict[str, Any]): + ctx = server.request_context + db = ctx.lifespan_context["db"] # Access startup resource + results = await db.query(arguments["query"]) + ``` Attributes: - request_id: Unique identifier for the current request - meta: Optional metadata from the request including progress token - session: The MCP session handling this request - lifespan_context: Application-specific context from lifespan initialization - request: The original request object, if available + request_id: Unique identifier for the current request as a [`RequestId`][mcp.types.RequestId]. + Use this for logging, tracing, or linking related operations. + meta: Optional request metadata including progress tokens and other client-provided + information. May be `None` if no metadata was provided. + session: The [`ServerSession`][mcp.server.session.ServerSession] for communicating + with the client. Use this to send responses, log messages, or check capabilities. + lifespan_context: Application-specific resources initialized during server startup. + Contains any objects yielded by the server's lifespan function. + request: The original request object from the client, if available. May be `None` + for some request types. + + Note: + This object is request-scoped and thread-safe within that scope. Each request + gets its own RequestContext instance. Don't store references to it beyond the + request lifecycle, as it becomes invalid when the request completes. """ request_id: RequestId From ecd3048ab5c91cea95b043e44990641c5e837b4c Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Wed, 20 Aug 2025 14:19:40 -0700 Subject: [PATCH 10/11] Fix mkdocs build warnings (#10) - Fix absolute link to relative in examples-lowlevel-servers.md - Add missing return type annotation to session property - Remove broken cross-references to type aliases (RequestId, ElicitationResult) - Update CLAUDE.md documentation build command --- CLAUDE.md | 4 ++++ docs/examples-lowlevel-servers.md | 2 +- src/mcp/server/fastmcp/server.py | 4 ++-- src/mcp/server/session.py | 4 ++-- src/mcp/shared/context.py | 2 +- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 09bcc7f02..627de2c90 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,10 @@ This document contains critical information about working with this codebase. Fo - Coverage: test edge cases and errors - New features require tests - Bug fixes require regression tests + - Documentation + - Test changes in docs/ and Python docstrings: `uv run mkdocs build` + - On macOS: `export DYLD_FALLBACK_LIBRARY_PATH=/opt/homebrew/lib & uv run mkdocs build` + - Fix WARNING and ERROR issues and re-run build until clean - For commits fixing bugs or adding features based on user reports add: diff --git a/docs/examples-lowlevel-servers.md b/docs/examples-lowlevel-servers.md index 5e65fb48f..e440cf2a4 100644 --- a/docs/examples-lowlevel-servers.md +++ b/docs/examples-lowlevel-servers.md @@ -1,6 +1,6 @@ # Low-level server examples -The [low-level server API](/python-sdk/reference/mcp/server/lowlevel/server/) provides maximum control over MCP protocol implementation. Use these patterns when you need fine-grained control or when [`FastMCP`][mcp.server.fastmcp.FastMCP] doesn't meet your requirements. +The [low-level server API](reference/mcp/server/lowlevel/server.md) provides maximum control over MCP protocol implementation. Use these patterns when you need fine-grained control or when [`FastMCP`][mcp.server.fastmcp.FastMCP] doesn't meet your requirements. The low-level API provides the foundation that FastMCP is built upon, giving you access to all MCP protocol features with complete control over implementation details. diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 291a01bcf..317128719 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -1487,7 +1487,7 @@ async def elicit( and simple containers (list, dict) are allowed - no complex nested objects. Returns: - [`ElicitationResult`][mcp.server.fastmcp.utilities.types.ElicitationResult] containing: + `ElicitationResult` containing: - `action`: One of "accept", "decline", or "cancel" indicating user response - `data`: The structured response data (only populated if action is "accept") @@ -1652,7 +1652,7 @@ async def traceable_tool(data: str, ctx: Context) -> str: return str(self.request_context.request_id) @property - def session(self): + def session(self) -> ServerSession: """Access to the underlying ServerSession for advanced MCP operations. This property provides direct access to the [`ServerSession`][mcp.server.session.ServerSession] diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index c51260527..edc42fd31 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -318,7 +318,7 @@ async def send_log_message( logger: Optional name to identify the source of the log message. Useful for categorizing logs from different components or modules within your server (e.g., "database", "auth", "tool_handler"). - related_request_id: Optional [`types.RequestId`][mcp.types.RequestId] linking this log to a specific client request. + related_request_id: Optional `types.RequestId` linking this log to a specific client request. Use this to associate log messages with the request they relate to, making it easier to trace request processing and debug issues. @@ -600,7 +600,7 @@ async def elicit( message: The prompt or question to present to the user. requestedSchema: A [`types.ElicitRequestedSchema`][mcp.types.ElicitRequestedSchema] defining the expected response structure according to JSON Schema. - related_request_id: Optional [`types.RequestId`][mcp.types.RequestId] linking + related_request_id: Optional `types.RequestId` linking this elicitation to a specific client request for tracing. Returns: diff --git a/src/mcp/shared/context.py b/src/mcp/shared/context.py index 68c0152cb..2e35935aa 100644 --- a/src/mcp/shared/context.py +++ b/src/mcp/shared/context.py @@ -77,7 +77,7 @@ async def query_data(name: str, arguments: dict[str, Any]): ``` Attributes: - request_id: Unique identifier for the current request as a [`RequestId`][mcp.types.RequestId]. + request_id: Unique identifier for the current request as a `RequestId`. Use this for logging, tracing, or linking related operations. meta: Optional request metadata including progress tokens and other client-provided information. May be `None` if no metadata was provided. From 685975c4af857eb8de3a72f006f91cee7ac9b20b Mon Sep 17 00:00:00 2001 From: Marsh Macy Date: Thu, 21 Aug 2025 14:23:43 -0700 Subject: [PATCH 11/11] docstring header fixup (#11) --- CLAUDE.md | 2 +- src/mcp/server/fastmcp/server.py | 118 +++++++++++++++--------------- src/mcp/server/lowlevel/server.py | 8 +- 3 files changed, 64 insertions(+), 64 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 627de2c90..c18ead0a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ This document contains critical information about working with this codebase. Fo 2. Code Quality - Type hints required for all code - - Public APIs must have docstrings + - All public members MUST have Google Python Style Guide-compliant docstrings - Functions must be focused and small - Follow existing patterns exactly - Line length: 120 chars maximum diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index 317128719..fb884efb8 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -371,54 +371,54 @@ def get_context(self) -> Context[ServerSession, LifespanResultT, Request]: a Context parameter in your tool/resource functions. Use this method only when you need context access from code that isn't directly called by FastMCP. - ## When to use this method + You might call this method directly in: - **Helper functions**: When context is needed in utility functions: + - **Helper functions** - ```python - mcp = FastMCP(name="example") + ```python + mcp = FastMCP(name="example") - async def log_operation(operation: str): - # Get context when it's not injected - ctx = mcp.get_context() - await ctx.info(f"Performing operation: {operation}") + async def log_operation(operation: str): + # Get context when it's not injected + ctx = mcp.get_context() + await ctx.info(f"Performing operation: {operation}") - @mcp.tool() - async def main_tool(data: str) -> str: - await log_operation("data_processing") # Helper needs context - return process_data(data) - ``` + @mcp.tool() + async def main_tool(data: str) -> str: + await log_operation("data_processing") # Helper needs context + return process_data(data) + ``` - **Callbacks and event handlers**: When context is needed in async callbacks: + - **Callbacks** and **event handlers** when context is needed in async callbacks - ```python - async def progress_callback(current: int, total: int): - ctx = mcp.get_context() # Access context in callback - await ctx.report_progress(current, total) + ```python + async def progress_callback(current: int, total: int): + ctx = mcp.get_context() # Access context in callback + await ctx.report_progress(current, total) - @mcp.tool() - async def long_operation(data: str) -> str: - return await process_with_callback(data, progress_callback) - ``` + @mcp.tool() + async def long_operation(data: str) -> str: + return await process_with_callback(data, progress_callback) + ``` - **Class methods**: When context is needed in class-based code: + - **Class methods** when context is needed in class-based code - ```python - class DataProcessor: - def __init__(self, mcp_server: FastMCP): - self.mcp = mcp_server - - async def process_chunk(self, chunk: str) -> str: - ctx = self.mcp.get_context() # Get context in method - await ctx.debug(f"Processing chunk of size {len(chunk)}") - return processed_chunk + ```python + class DataProcessor: + def __init__(self, mcp_server: FastMCP): + self.mcp = mcp_server - processor = DataProcessor(mcp) + async def process_chunk(self, chunk: str) -> str: + ctx = self.mcp.get_context() # Get context in method + await ctx.debug(f"Processing chunk of size {len(chunk)}") + return processed_chunk - @mcp.tool() - async def process_data(data: str) -> str: - return await processor.process_chunk(data) - ``` + processor = DataProcessor(mcp) + + @mcp.tool() + async def process_data(data: str) -> str: + return await processor.process_chunk(data) + ``` Returns: [`Context`][mcp.server.fastmcp.Context] object for the current request @@ -1247,7 +1247,7 @@ async def simple_tool(data: str) -> str: # No context needed return f"Processed: {data}" - @mcp.tool() + @mcp.tool() async def advanced_tool(data: str, ctx: Context) -> str: # Context automatically injected await ctx.info("Starting processing") @@ -1271,7 +1271,7 @@ async def advanced_tool(data: str, ctx: Context) -> str: ```python await ctx.debug("Detailed debug information") await ctx.info("General status updates") - await ctx.warning("Important warnings") + await ctx.warning("Important warnings") await ctx.error("Error conditions") ``` @@ -1289,7 +1289,7 @@ async def advanced_tool(data: str, ctx: Context) -> str: class UserPrefs(BaseModel): format: str detailed: bool - + result = await ctx.elicit("How should I format the output?", UserPrefs) if result.action == "accept": format_data(data, result.data.format) @@ -1325,12 +1325,12 @@ class ProcessingOptions(BaseModel): @mcp.tool() async def process_data( - data: str, + data: str, ctx: Context, auto_format: bool = False ) -> str: await ctx.info(f"Starting to process {len(data)} characters") - + # Get user preferences if not auto-formatting if not auto_format: if ctx.session.check_client_capability( @@ -1348,18 +1348,18 @@ async def process_data( format_type = "standard" include_meta = False else: - format_type = "standard" + format_type = "standard" include_meta = False else: format_type = "auto" include_meta = True - + # Process with progress updates for i in range(0, len(data), 100): chunk = data[i:i+100] await ctx.report_progress(i, len(data), f"Processing chunk {i//100 + 1}") # ... process chunk - + await ctx.info(f"Processing complete with format: {format_type}") return processed_data ``` @@ -1418,10 +1418,10 @@ def request_context( async def advanced_tool(data: str, ctx: Context) -> str: # Access lifespan context directly db = ctx.request_context.lifespan_context["database"] - + # Access request metadata progress_token = ctx.request_context.meta.progressToken if ctx.request_context.meta else None - + return processed_data ``` """ @@ -1470,11 +1470,11 @@ async def elicit( This method enables interactive data collection from clients during tool processing. The client may display the message to the user and collect a response according to - the provided Pydantic schema, or if the client is an agent, it may automatically + the provided Pydantic schema, or if the client is an agent, it may automatically generate an appropriate response. This is useful for gathering additional parameters, user preferences, or confirmation before proceeding with operations. - You typically access this method through the [`Context`][mcp.server.fastmcp.Context] + You typically access this method through the [`Context`][mcp.server.fastmcp.Context] object injected into your FastMCP tool functions. Always check that the client supports elicitation using [`check_client_capability`][mcp.server.session.ServerSession.check_client_capability] before calling this method. @@ -1482,7 +1482,7 @@ async def elicit( Args: message: The prompt or question to present to the user. Should clearly explain what information is being requested and why it's needed. - schema: A Pydantic model class defining the expected response structure. + schema: A Pydantic model class defining the expected response structure. According to the MCP specification, only primitive types (str, int, float, bool) and simple containers (list, dict) are allowed - no complex nested objects. @@ -1519,13 +1519,13 @@ async def process_data(data: str, ctx: Context) -> str: ): # Fall back to default processing return process_with_defaults(data) - + # Ask user for processing preferences result = await ctx.elicit( "How would you like me to process this data?", ProcessingOptions ) - + if result.action == "accept": options = result.data await ctx.info(f"Processing with format: {options.format}") @@ -1546,12 +1546,12 @@ class ConfirmDelete(BaseModel): @mcp.tool() async def delete_files(pattern: str, ctx: Context) -> str: files = find_matching_files(pattern) - + result = await ctx.elicit( f"About to delete {len(files)} files matching '{pattern}'. Continue?", ConfirmDelete ) - + if result.action == "accept" and result.data.confirm: await ctx.info(f"Deletion confirmed: {result.data.reason}") return delete_files(files) @@ -1572,7 +1572,7 @@ async def configure_system(ctx: Context) -> str: "How should I configure the system?", UserChoice ) - + match result.action: case "accept": choice = result.data @@ -1642,10 +1642,10 @@ def request_id(self) -> str: async def traceable_tool(data: str, ctx: Context) -> str: # Log with request ID for traceability print(f"Processing request {ctx.request_id}") - + # Request ID is automatically included in Context methods await ctx.info("Starting processing") # Links to this request - + return processed_data ``` """ @@ -1664,7 +1664,7 @@ def session(self) -> ServerSession: which internally use this session with appropriate request linking. Returns: - [`ServerSession`][mcp.server.session.ServerSession]: The session for + [`ServerSession`][mcp.server.session.ServerSession]: The session for communicating with the client and accessing advanced MCP features. Examples: @@ -1693,7 +1693,7 @@ async def advanced_tool(data: str, ctx: Context) -> str: @mcp.tool() async def update_resource(uri: str, ctx: Context) -> str: # ... update the resource ... - + # Notify client of resource changes await ctx.session.send_resource_updated(AnyUrl(uri)) return "Resource updated" diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 2990960b8..b65279b23 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -257,16 +257,16 @@ def request_context( client request. The context is automatically managed by the server and is only available during request processing. - ## Common usage patterns + Examples: **Logging and communication**: - ```python + ```python @app.call_tool() async def my_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: ctx = app.request_context await ctx.session.send_log_message( - level="info", + level="info", data="Starting tool processing", related_request_id=ctx.request_id ) @@ -275,7 +275,7 @@ async def my_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlo **Capability checking**: ```python - @app.call_tool() + @app.call_tool() async def advanced_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: ctx = app.request_context if ctx.session.check_client_capability(