From e7d79e0775b4d104c699771da47da72dddee4182 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 29 Jun 2025 22:30:47 +0100 Subject: [PATCH 01/25] everything server for REAME snippets --- .github/workflows/shared.yml | 16 ++ .pre-commit-config.yaml | 6 + README.md | 30 ++- examples/servers/everything/README.md | 43 +++ examples/servers/everything/pyproject.toml | 18 ++ .../everything/src/everything/__init__.py | 5 + .../everything/src/everything/__main__.py | 14 + .../everything/src/everything/server.py | 255 ++++++++++++++++++ scripts/update_readme_snippets.py | 202 ++++++++++++++ tests/server/fastmcp/test_integration.py | 236 +--------------- uv.lock | 18 ++ 11 files changed, 598 insertions(+), 245 deletions(-) create mode 100644 examples/servers/everything/README.md create mode 100644 examples/servers/everything/pyproject.toml create mode 100644 examples/servers/everything/src/everything/__init__.py create mode 100644 examples/servers/everything/src/everything/__main__.py create mode 100644 examples/servers/everything/src/everything/server.py create mode 100755 scripts/update_readme_snippets.py diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index 0e6d18364..adf5dbbaf 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -46,3 +46,19 @@ jobs: - name: Run pytest run: uv run --frozen --no-sync pytest continue-on-error: true + + readme-snippets: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + version: 0.7.2 + + - name: Install dependencies + run: uv sync --frozen --all-extras --python 3.10 + + - name: Check README snippets are up to date + run: uv run --frozen scripts/update_readme_snippets.py --check diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 35e12261a..ef7a33b74 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,3 +36,9 @@ repos: language: system files: ^(pyproject\.toml|uv\.lock)$ pass_filenames: false + - id: readme-snippets + name: Check README snippets are up to date + entry: uv run scripts/update_readme_snippets.py --check + language: system + files: ^(README\.md|examples/.*\.py|scripts/update_readme_snippets\.py)$ + pass_filenames: false diff --git a/README.md b/README.md index 412143a9f..d0fa696f0 100644 --- a/README.md +++ b/README.md @@ -419,21 +419,27 @@ def create_thumbnail(image_path: str) -> Image: The Context object gives your tools and resources access to MCP capabilities: + ```python -from mcp.server.fastmcp import FastMCP, Context - -mcp = FastMCP("My App") - + # Tool with context for logging and progress + @mcp.tool(description="A tool that demonstrates logging and progress", title="Progress Tool") + async def tool_with_progress(message: str, ctx: Context, steps: int = 3) -> str: + await ctx.info(f"Starting processing of '{message}' with {steps} steps") + + # Send progress notifications + for i in range(steps): + progress_value = (i + 1) / steps + await ctx.report_progress( + progress=progress_value, + total=1.0, + message=f"Processing step {i + 1} of {steps}", + ) + await ctx.debug(f"Completed step {i + 1}") -@mcp.tool() -async def long_task(files: list[str], ctx: Context) -> str: - """Process multiple files with progress tracking""" - for i, file in enumerate(files): - ctx.info(f"Processing {file}") - await ctx.report_progress(i, len(files)) - data, mime_type = await ctx.read_resource(f"file://{file}") - return "Processing complete" + return f"Processed '{message}' in {steps} steps" ``` +_Full example: [examples/servers/everything/src/everything/server.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/servers/everything/src/everything/server.py#L43-L58)_ + ### Completions diff --git a/examples/servers/everything/README.md b/examples/servers/everything/README.md new file mode 100644 index 000000000..2d874f5ce --- /dev/null +++ b/examples/servers/everything/README.md @@ -0,0 +1,43 @@ +# MCP Everything Server + +A comprehensive example MCP server that demonstrates all features of the Model Context Protocol, including: + +- Tools with progress reporting, logging, and elicitation +- Resource handling (static, dynamic, and templated) +- Prompts with arguments +- Completion support for resource templates and prompts +- Request context propagation +- Notifications and logging +- Sampling capabilities +- Structured output + +## Usage + +### Running the server + +```bash +uv run mcp-server-everything +``` + +### Testing with MCP Inspector + +```bash +mcp dev "uv run mcp-server-everything" +``` + +### Installing in Claude Desktop + +```bash +mcp install "uv run mcp-server-everything" --name "Everything Server" +``` + +## Features + +This server demonstrates: + +- **Tools**: Echo, progress tracking, sampling, notifications, context access, elicitation +- **Resources**: Static resources, dynamic resources with parameters, resource templates +- **Prompts**: Simple and complex prompts with arguments +- **Completions**: Context-aware completion for prompts and resource templates +- **Notifications**: Progress updates, logging at different levels, resource/tool list changes +- **Elicitation**: Interactive user input during tool execution \ No newline at end of file diff --git a/examples/servers/everything/pyproject.toml b/examples/servers/everything/pyproject.toml new file mode 100644 index 000000000..c4c7e2c94 --- /dev/null +++ b/examples/servers/everything/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "mcp-server-everything" +version = "0.1.0" +description = "Comprehensive MCP server demonstrating all protocol features" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "mcp", + "pydantic>=2.0", + "httpx", +] + +[project.scripts] +mcp-server-everything = "everything.__main__:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" \ No newline at end of file diff --git a/examples/servers/everything/src/everything/__init__.py b/examples/servers/everything/src/everything/__init__.py new file mode 100644 index 000000000..7769dfb21 --- /dev/null +++ b/examples/servers/everything/src/everything/__init__.py @@ -0,0 +1,5 @@ +"""MCP Everything Server - Comprehensive example demonstrating all MCP features.""" + +from .server import create_everything_server + +__all__ = ["create_everything_server"] \ No newline at end of file diff --git a/examples/servers/everything/src/everything/__main__.py b/examples/servers/everything/src/everything/__main__.py new file mode 100644 index 000000000..409dd0b28 --- /dev/null +++ b/examples/servers/everything/src/everything/__main__.py @@ -0,0 +1,14 @@ +"""Main entry point for the Everything Server.""" + +from .server import create_everything_server + + +def main(): + """Run the everything server.""" + mcp = create_everything_server() + # Use FastMCP's built-in run method for better CLI integration + mcp.run(transport="sse") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/servers/everything/src/everything/server.py b/examples/servers/everything/src/everything/server.py new file mode 100644 index 000000000..0a61e1ff6 --- /dev/null +++ b/examples/servers/everything/src/everything/server.py @@ -0,0 +1,255 @@ +""" +Everything Server - Comprehensive MCP FastMCP Server +Shows all MCP features including tools, resources, prompts, sampling, and more. + +This example demonstrates the 2025-06-18 protocol features including: +- Tools with progress reporting, logging, and elicitation +- Resource handling (static, dynamic, and templated) +- Prompts with arguments +- Completion support for resource templates and prompts +- Request context propagation +- Notifications and logging +- Sampling capabilities +""" + +import json +from typing import Any + +from pydantic import AnyUrl, BaseModel, Field +from starlette.requests import Request + +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.fastmcp.resources import FunctionResource +from mcp.server.transport_security import TransportSecuritySettings +from mcp.types import ( + Completion, + CompletionArgument, + CompletionContext, + PromptReference, + ResourceLink, + ResourceTemplateReference, + SamplingMessage, + TextContent, +) + + +def create_everything_server() -> FastMCP: + """Create a comprehensive FastMCP server with all features enabled.""" + transport_security = TransportSecuritySettings( + allowed_hosts=["127.0.0.1:*", "localhost:*"], allowed_origins=["http://127.0.0.1:*", "http://localhost:*"] + ) + mcp = FastMCP(name="EverythingServer", transport_security=transport_security) + + # Tool with context for logging and progress + @mcp.tool(description="A tool that demonstrates logging and progress", title="Progress Tool") + async def tool_with_progress(message: str, ctx: Context, steps: int = 3) -> str: + await ctx.info(f"Starting processing of '{message}' with {steps} steps") + + # Send progress notifications + for i in range(steps): + progress_value = (i + 1) / steps + await ctx.report_progress( + progress=progress_value, + total=1.0, + message=f"Processing step {i + 1} of {steps}", + ) + await ctx.debug(f"Completed step {i + 1}") + + return f"Processed '{message}' in {steps} steps" + + # Simple tool for basic functionality + @mcp.tool(description="A simple echo tool", title="Echo Tool") + def echo(message: str) -> str: + return f"Echo: {message}" + + # Tool that returns ResourceLinks + @mcp.tool(description="Lists files and returns resource links", title="List Files Tool") + def list_files() -> list[ResourceLink]: + """Returns a list of resource links for files matching the pattern.""" + + # Sample file resources + file_resources = [ + { + "type": "resource_link", + "uri": "file:///project/README.md", + "name": "README.md", + "mimeType": "text/markdown", + } + ] + + result: list[ResourceLink] = [ResourceLink.model_validate(file_json) for file_json in file_resources] + return result + + # Tool with sampling capability + @mcp.tool(description="A tool that uses sampling to generate content", title="Sampling Tool") + async def sampling_tool(prompt: str, ctx: Context) -> str: + await ctx.info(f"Requesting sampling for prompt: {prompt}") + + # Request sampling from the client + result = await ctx.session.create_message( + messages=[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))], + max_tokens=100, + temperature=0.7, + ) + + await ctx.info(f"Received sampling result from model: {result.model}") + # Handle different content types + if result.content.type == "text": + return f"Sampling result: {result.content.text[:100]}..." + else: + return f"Sampling result: {str(result.content)[:100]}..." + + # Tool that sends notifications and logging + @mcp.tool(description="A tool that demonstrates notifications and logging", title="Notification Tool") + async def notification_tool(message: str, ctx: Context) -> str: + # Send different log levels + await ctx.debug("Debug: Starting notification tool") + await ctx.info(f"Info: Processing message '{message}'") + await ctx.warning("Warning: This is a test warning") + + # Send resource change notifications + await ctx.session.send_resource_list_changed() + await ctx.session.send_tool_list_changed() + + await ctx.info("Completed notification tool successfully") + return f"Sent notifications and logs for: {message}" + + # Resource - static + def get_static_info() -> str: + return "This is static resource content" + + static_resource = FunctionResource( + uri=AnyUrl("resource://static/info"), + name="Static Info", + title="Static Information", + description="Static information resource", + fn=get_static_info, + ) + mcp.add_resource(static_resource) + + # Resource - dynamic function + @mcp.resource("resource://dynamic/{category}", title="Dynamic Resource") + def dynamic_resource(category: str) -> str: + return f"Dynamic resource content for category: {category}" + + # Resource template + @mcp.resource("resource://template/{id}/data", title="Template Resource") + def template_resource(id: str) -> str: + return f"Template resource data for ID: {id}" + + # Prompt - simple + @mcp.prompt(description="A simple prompt", title="Simple Prompt") + def simple_prompt(topic: str) -> str: + return f"Tell me about {topic}" + + # Prompt - complex with multiple arguments + @mcp.prompt(description="Complex prompt with context", title="Complex Prompt") + def complex_prompt(user_query: str, context: str = "general") -> str: + # Return a single string that incorporates the context + return f"Context: {context}. Query: {user_query}" + + # Resource template with completion support + @mcp.resource("github://repos/{owner}/{repo}", title="GitHub Repository") + def github_repo_resource(owner: str, repo: str) -> str: + return f"Repository: {owner}/{repo}" + + # Add completion handler for the server + @mcp.completion() + async def handle_completion( + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, + ) -> Completion | None: + # Handle GitHub repository completion + if isinstance(ref, ResourceTemplateReference): + if ref.uri == "github://repos/{owner}/{repo}" and argument.name == "repo": + if context and context.arguments and context.arguments.get("owner") == "modelcontextprotocol": + # Return repos for modelcontextprotocol org + return Completion(values=["python-sdk", "typescript-sdk", "specification"], total=3, hasMore=False) + elif context and context.arguments and context.arguments.get("owner") == "test-org": + # Return repos for test-org + return Completion(values=["test-repo1", "test-repo2"], total=2, hasMore=False) + + # Handle prompt completions + if isinstance(ref, PromptReference): + if ref.name == "complex_prompt" and argument.name == "context": + # Complete context values + contexts = ["general", "technical", "business", "academic"] + return Completion( + values=[c for c in contexts if c.startswith(argument.value)], total=None, hasMore=False + ) + + # Default: no completion available + return Completion(values=[], total=0, hasMore=False) + + # Tool that echoes request headers from context + @mcp.tool(description="Echo request headers from context", title="Echo Headers") + def echo_headers(ctx: Context[Any, Any, Request]) -> str: + """Returns the request headers as JSON.""" + headers_info = {} + if ctx.request_context.request: + # Now the type system knows request is a Starlette Request object + headers_info = dict(ctx.request_context.request.headers) + return json.dumps(headers_info) + + # Tool that returns full request context + @mcp.tool(description="Echo request context with custom data", title="Echo Context") + def echo_context(custom_request_id: str, ctx: Context[Any, Any, Request]) -> str: + """Returns request context including headers and custom data.""" + context_data = { + "custom_request_id": custom_request_id, + "headers": {}, + "method": None, + "path": None, + } + if ctx.request_context.request: + request = ctx.request_context.request + context_data["headers"] = dict(request.headers) + context_data["method"] = request.method + context_data["path"] = request.url.path + return json.dumps(context_data) + + # Restaurant booking tool with elicitation + @mcp.tool(description="Book a table at a restaurant with elicitation", title="Restaurant Booking") + async def book_restaurant( + date: str, + time: str, + party_size: int, + ctx: Context, + ) -> str: + """Book a table - uses elicitation if requested date is unavailable.""" + + class AlternativeDateSchema(BaseModel): + checkAlternative: bool = Field(description="Would you like to try another date?") + alternativeDate: str = Field( + default="2024-12-26", + description="What date would you prefer? (YYYY-MM-DD)", + ) + + # For demo: assume dates starting with "2024-12-25" are unavailable + if date.startswith("2024-12-25"): + # Use elicitation to ask about alternatives + result = await ctx.elicit( + message=( + f"No tables available for {party_size} people on {date} " + f"at {time}. Would you like to check another date?" + ), + schema=AlternativeDateSchema, + ) + + if result.action == "accept" and result.data: + if result.data.checkAlternative: + alt_date = result.data.alternativeDate + return f"✅ Booked table for {party_size} on {alt_date} at {time}" + else: + return "❌ No booking made" + elif result.action in ("decline", "cancel"): + return "❌ Booking cancelled" + else: + # Handle case where action is "accept" but data is None + return "❌ No booking data received" + else: + # Available - book directly + return f"✅ Booked table for {party_size} on {date} at {time}" + + return mcp \ No newline at end of file diff --git a/scripts/update_readme_snippets.py b/scripts/update_readme_snippets.py new file mode 100755 index 000000000..8cf49c909 --- /dev/null +++ b/scripts/update_readme_snippets.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +""" +Update README.md with live code snippets from example files. + +This script finds specially marked code blocks in README.md and updates them +with the actual code from the referenced files. + +Usage: + python scripts/update_readme_snippets.py + python scripts/update_readme_snippets.py --check # Check mode for CI +""" + +import argparse +import re +import sys +from pathlib import Path + + +def extract_lines(file_path: Path, start_line: int, end_line: int) -> str: + """Extract lines from a file. + + Args: + file_path: Path to the file + start_line: Starting line number (1-based) + end_line: Ending line number (1-based, inclusive) + + Returns: + The extracted lines + """ + content = file_path.read_text() + lines = content.splitlines() + # Convert to 0-based indexing + start_idx = max(0, start_line - 1) + end_idx = min(len(lines), end_line) + return '\n'.join(lines[start_idx:end_idx]) + + +def get_github_url(file_path: str, start_line: int, end_line: int) -> str: + """Generate a GitHub URL for the file with line highlighting. + + Args: + file_path: Path to the file relative to repo root + start_line: Starting line number + end_line: Ending line number + + Returns: + GitHub URL with line highlighting + """ + base_url = "https://github.com/modelcontextprotocol/python-sdk/blob/main" + url = f"{base_url}/{file_path}" + + if start_line == end_line: + url += f"#L{start_line}" + else: + url += f"#L{start_line}-L{end_line}" + + return url + + +def process_snippet_block(match: re.Match, check_mode: bool = False) -> str: + """Process a single snippet-source block. + + Args: + match: The regex match object + check_mode: If True, return original if no changes needed + + Returns: + The updated block content + """ + full_match = match.group(0) + indent = match.group(1) + file_path = match.group(2) + start_line = int(match.group(3)) + end_line = int(match.group(4)) + + try: + # Read and extract the code + file = Path(file_path) + if not file.exists(): + print(f"Warning: File not found: {file_path}") + return full_match + + code = extract_lines(file, start_line, end_line) + + # Generate GitHub URL + github_url = get_github_url(file_path, start_line, end_line) + + # Build the replacement block + replacement = f"""{indent} +{indent}```python +{code} +{indent}``` +{indent}_Full example: [{file_path}]({github_url})_ +{indent}""" + + # In check mode, only check if code has changed + if check_mode: + # Extract existing code from the match + existing_content = match.group(5) + if existing_content is not None: + existing_lines = existing_content.strip().split('\n') + # Find code between ```python and ``` + code_lines = [] + in_code = False + for line in existing_lines: + if line.strip() == '```python': + in_code = True + elif line.strip() == '```': + break + elif in_code: + code_lines.append(line) + existing_code = '\n'.join(code_lines).strip() + if existing_code == code.strip(): + return full_match + + return replacement + + except Exception as e: + print(f"Error processing {file_path}#L{start_line}-L{end_line}: {e}") + return full_match + + +def update_readme_snippets(readme_path: Path = Path("README.md"), check_mode: bool = False) -> bool: + """Update code snippets in README.md with live code from source files. + + Args: + readme_path: Path to the README file + check_mode: If True, only check if updates are needed without modifying + + Returns: + True if file is up to date or was updated, False if check failed + """ + if not readme_path.exists(): + print(f"Error: README file not found: {readme_path}") + return False + + content = readme_path.read_text() + original_content = content + + # Pattern to match snippet-source blocks with line ranges + # Matches: + # ... any content ... + # + pattern = ( + r'^(\s*)\n' + r'(.*?)' + r'^\1' + ) + + # Process all snippet-source blocks + updated_content = re.sub( + pattern, + lambda m: process_snippet_block(m, check_mode), + content, + flags=re.MULTILINE | re.DOTALL + ) + + if check_mode: + if updated_content != original_content: + print( + f"Error: {readme_path} has outdated code snippets. " + "Run 'python scripts/update_readme_snippets.py' to update." + ) + return False + else: + print(f"✓ {readme_path} code snippets are up to date") + return True + else: + if updated_content != original_content: + readme_path.write_text(updated_content) + print(f"✓ Updated {readme_path}") + else: + print(f"✓ {readme_path} already up to date") + return True + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser( + description='Update README code snippets from source files' + ) + parser.add_argument( + '--check', + action='store_true', + help='Check mode - verify snippets are up to date without modifying' + ) + parser.add_argument( + '--readme', + default='README.md', + help='Path to README file (default: README.md)' + ) + + args = parser.parse_args() + + success = update_readme_snippets(Path(args.readme), check_mode=args.check) + + if not success: + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/server/fastmcp/test_integration.py b/tests/server/fastmcp/test_integration.py index 526201f9a..be5524bfe 100644 --- a/tests/server/fastmcp/test_integration.py +++ b/tests/server/fastmcp/test_integration.py @@ -10,25 +10,20 @@ import socket import time from collections.abc import Generator -from typing import Any import pytest import uvicorn from pydantic import AnyUrl, BaseModel, Field from starlette.applications import Starlette -from starlette.requests import Request +from examples.servers.everything.src.everything import create_everything_server from mcp.client.session import ClientSession from mcp.client.sse import sse_client from mcp.client.streamable_http import streamablehttp_client from mcp.server.fastmcp import Context, FastMCP -from mcp.server.fastmcp.resources import FunctionResource from mcp.server.transport_security import TransportSecuritySettings from mcp.shared.context import RequestContext from mcp.types import ( - Completion, - CompletionArgument, - CompletionContext, CreateMessageRequestParams, CreateMessageResult, ElicitResult, @@ -41,7 +36,6 @@ ResourceLink, ResourceListChangedNotification, ResourceTemplateReference, - SamplingMessage, ServerNotification, TextContent, TextResourceContents, @@ -124,233 +118,11 @@ class AnswerSchema(BaseModel): return mcp, app -def make_everything_fastmcp() -> FastMCP: - """Create a FastMCP server with all features enabled for testing.""" - transport_security = TransportSecuritySettings( - allowed_hosts=["127.0.0.1:*", "localhost:*"], allowed_origins=["http://127.0.0.1:*", "http://localhost:*"] - ) - mcp = FastMCP(name="EverythingServer", transport_security=transport_security) - - # Tool with context for logging and progress - @mcp.tool(description="A tool that demonstrates logging and progress", title="Progress Tool") - async def tool_with_progress(message: str, ctx: Context, steps: int = 3) -> str: - await ctx.info(f"Starting processing of '{message}' with {steps} steps") - - # Send progress notifications - for i in range(steps): - progress_value = (i + 1) / steps - await ctx.report_progress( - progress=progress_value, - total=1.0, - message=f"Processing step {i + 1} of {steps}", - ) - await ctx.debug(f"Completed step {i + 1}") - - return f"Processed '{message}' in {steps} steps" - - # Simple tool for basic functionality - @mcp.tool(description="A simple echo tool", title="Echo Tool") - def echo(message: str) -> str: - return f"Echo: {message}" - - # Tool that returns ResourceLinks - @mcp.tool(description="Lists files and returns resource links", title="List Files Tool") - def list_files() -> list[ResourceLink]: - """Returns a list of resource links for files matching the pattern.""" - - # Mock some file resources for testing - file_resources = [ - { - "type": "resource_link", - "uri": "file:///project/README.md", - "name": "README.md", - "mimeType": "text/markdown", - } - ] - - result: list[ResourceLink] = [ResourceLink.model_validate(file_json) for file_json in file_resources] - - return result - - # Tool with sampling capability - @mcp.tool(description="A tool that uses sampling to generate content", title="Sampling Tool") - async def sampling_tool(prompt: str, ctx: Context) -> str: - await ctx.info(f"Requesting sampling for prompt: {prompt}") - - # Request sampling from the client - result = await ctx.session.create_message( - messages=[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))], - max_tokens=100, - temperature=0.7, - ) - - await ctx.info(f"Received sampling result from model: {result.model}") - # Handle different content types - if result.content.type == "text": - return f"Sampling result: {result.content.text[:100]}..." - else: - return f"Sampling result: {str(result.content)[:100]}..." - - # Tool that sends notifications and logging - @mcp.tool(description="A tool that demonstrates notifications and logging", title="Notification Tool") - async def notification_tool(message: str, ctx: Context) -> str: - # Send different log levels - await ctx.debug("Debug: Starting notification tool") - await ctx.info(f"Info: Processing message '{message}'") - await ctx.warning("Warning: This is a test warning") - - # Send resource change notifications - await ctx.session.send_resource_list_changed() - await ctx.session.send_tool_list_changed() - - await ctx.info("Completed notification tool successfully") - return f"Sent notifications and logs for: {message}" - - # Resource - static - def get_static_info() -> str: - return "This is static resource content" - - static_resource = FunctionResource( - uri=AnyUrl("resource://static/info"), - name="Static Info", - title="Static Information", - description="Static information resource", - fn=get_static_info, - ) - mcp.add_resource(static_resource) - - # Resource - dynamic function - @mcp.resource("resource://dynamic/{category}", title="Dynamic Resource") - def dynamic_resource(category: str) -> str: - return f"Dynamic resource content for category: {category}" - - # Resource template - @mcp.resource("resource://template/{id}/data", title="Template Resource") - def template_resource(id: str) -> str: - return f"Template resource data for ID: {id}" - - # Prompt - simple - @mcp.prompt(description="A simple prompt", title="Simple Prompt") - def simple_prompt(topic: str) -> str: - return f"Tell me about {topic}" - - # Prompt - complex with multiple messages - @mcp.prompt(description="Complex prompt with context", title="Complex Prompt") - def complex_prompt(user_query: str, context: str = "general") -> str: - # For simplicity, return a single string that incorporates the context - # Since FastMCP doesn't support system messages in the same way - return f"Context: {context}. Query: {user_query}" - - # Resource template with completion support - @mcp.resource("github://repos/{owner}/{repo}", title="GitHub Repository") - def github_repo_resource(owner: str, repo: str) -> str: - return f"Repository: {owner}/{repo}" - - # Add completion handler for the server - @mcp.completion() - async def handle_completion( - ref: PromptReference | ResourceTemplateReference, - argument: CompletionArgument, - context: CompletionContext | None, - ) -> Completion | None: - # Handle GitHub repository completion - if isinstance(ref, ResourceTemplateReference): - if ref.uri == "github://repos/{owner}/{repo}" and argument.name == "repo": - if context and context.arguments and context.arguments.get("owner") == "modelcontextprotocol": - # Return repos for modelcontextprotocol org - return Completion(values=["python-sdk", "typescript-sdk", "specification"], total=3, hasMore=False) - elif context and context.arguments and context.arguments.get("owner") == "test-org": - # Return repos for test-org - return Completion(values=["test-repo1", "test-repo2"], total=2, hasMore=False) - - # Handle prompt completions - if isinstance(ref, PromptReference): - if ref.name == "complex_prompt" and argument.name == "context": - # Complete context values - contexts = ["general", "technical", "business", "academic"] - return Completion( - values=[c for c in contexts if c.startswith(argument.value)], total=None, hasMore=False - ) - - # Default: no completion available - return Completion(values=[], total=0, hasMore=False) - - # Tool that echoes request headers from context - @mcp.tool(description="Echo request headers from context", title="Echo Headers") - def echo_headers(ctx: Context[Any, Any, Request]) -> str: - """Returns the request headers as JSON.""" - headers_info = {} - if ctx.request_context.request: - # Now the type system knows request is a Starlette Request object - headers_info = dict(ctx.request_context.request.headers) - return json.dumps(headers_info) - - # Tool that returns full request context - @mcp.tool(description="Echo request context with custom data", title="Echo Context") - def echo_context(custom_request_id: str, ctx: Context[Any, Any, Request]) -> str: - """Returns request context including headers and custom data.""" - context_data = { - "custom_request_id": custom_request_id, - "headers": {}, - "method": None, - "path": None, - } - if ctx.request_context.request: - request = ctx.request_context.request - context_data["headers"] = dict(request.headers) - context_data["method"] = request.method - context_data["path"] = request.url.path - return json.dumps(context_data) - - # Restaurant booking tool with elicitation - @mcp.tool(description="Book a table at a restaurant with elicitation", title="Restaurant Booking") - async def book_restaurant( - date: str, - time: str, - party_size: int, - ctx: Context, - ) -> str: - """Book a table - uses elicitation if requested date is unavailable.""" - - class AlternativeDateSchema(BaseModel): - checkAlternative: bool = Field(description="Would you like to try another date?") - alternativeDate: str = Field( - default="2024-12-26", - description="What date would you prefer? (YYYY-MM-DD)", - ) - - # For testing: assume dates starting with "2024-12-25" are unavailable - if date.startswith("2024-12-25"): - # Use elicitation to ask about alternatives - result = await ctx.elicit( - message=( - f"No tables available for {party_size} people on {date} " - f"at {time}. Would you like to check another date?" - ), - schema=AlternativeDateSchema, - ) - - if result.action == "accept" and result.data: - if result.data.checkAlternative: - alt_date = result.data.alternativeDate - return f"✅ Booked table for {party_size} on {alt_date} at {time}" - else: - return "❌ No booking made" - elif result.action in ("decline", "cancel"): - return "❌ Booking cancelled" - else: - # Handle case where action is "accept" but data is None - return "❌ No booking data received" - else: - # Available - book directly - return f"✅ Booked table for {party_size} on {date} at {time}" - - return mcp def make_everything_fastmcp_app(): """Create a comprehensive FastMCP server with SSE transport.""" - mcp = make_everything_fastmcp() + mcp = create_everything_server() # Create the SSE app app = mcp.sse_app() return mcp, app @@ -376,9 +148,7 @@ def echo(message: str) -> str: def make_everything_fastmcp_streamable_http_app(): """Create a comprehensive FastMCP server with StreamableHTTP transport.""" - # Create a new instance with different name for HTTP transport - mcp = make_everything_fastmcp() - # We can't change the name after creation, so we'll use the same name + mcp = create_everything_server() # Create the StreamableHTTP app app: Starlette = mcp.streamable_http_app() return mcp, app diff --git a/uv.lock b/uv.lock index cfcc8238e..832d4a60d 100644 --- a/uv.lock +++ b/uv.lock @@ -8,6 +8,7 @@ resolution-mode = "lowest-direct" [manifest] members = [ "mcp", + "mcp-server-everything", "mcp-simple-auth", "mcp-simple-prompt", "mcp-simple-resource", @@ -637,6 +638,23 @@ docs = [ { name = "mkdocstrings-python", specifier = ">=1.12.2" }, ] +[[package]] +name = "mcp-server-everything" +version = "0.1.0" +source = { editable = "examples/servers/everything" } +dependencies = [ + { name = "httpx" }, + { name = "mcp" }, + { name = "pydantic" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx" }, + { name = "mcp", editable = "." }, + { name = "pydantic", specifier = ">=2.0" }, +] + [[package]] name = "mcp-simple-auth" version = "0.1.0" From f02996cc9345d4088868a5fb95e78cb442ae2033 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 29 Jun 2025 22:48:07 +0100 Subject: [PATCH 02/25] simplitfy readme for everything server --- examples/servers/everything/README.md | 34 +++------------------------ 1 file changed, 3 insertions(+), 31 deletions(-) diff --git a/examples/servers/everything/README.md b/examples/servers/everything/README.md index 2d874f5ce..392e5ec1e 100644 --- a/examples/servers/everything/README.md +++ b/examples/servers/everything/README.md @@ -1,15 +1,10 @@ # MCP Everything Server -A comprehensive example MCP server that demonstrates all features of the Model Context Protocol, including: +MCP server that demonstrates all features of the Model Context Protocol, including: -- Tools with progress reporting, logging, and elicitation +- Tools with progress reporting, logging, and elicitation and sampling, structured output - Resource handling (static, dynamic, and templated) -- Prompts with arguments -- Completion support for resource templates and prompts -- Request context propagation -- Notifications and logging -- Sampling capabilities -- Structured output +- Prompts with arguments and completions ## Usage @@ -18,26 +13,3 @@ A comprehensive example MCP server that demonstrates all features of the Model C ```bash uv run mcp-server-everything ``` - -### Testing with MCP Inspector - -```bash -mcp dev "uv run mcp-server-everything" -``` - -### Installing in Claude Desktop - -```bash -mcp install "uv run mcp-server-everything" --name "Everything Server" -``` - -## Features - -This server demonstrates: - -- **Tools**: Echo, progress tracking, sampling, notifications, context access, elicitation -- **Resources**: Static resources, dynamic resources with parameters, resource templates -- **Prompts**: Simple and complex prompts with arguments -- **Completions**: Context-aware completion for prompts and resource templates -- **Notifications**: Progress updates, logging at different levels, resource/tool list changes -- **Elicitation**: Interactive user input during tool execution \ No newline at end of file From 1bbacf7dd890adb01d1bdc71d68dbe60f656492a Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 29 Jun 2025 22:50:16 +0100 Subject: [PATCH 03/25] more comment simplifications --- examples/servers/everything/pyproject.toml | 2 +- .../servers/everything/src/everything/__main__.py | 5 ++--- .../servers/everything/src/everything/server.py | 14 ++++---------- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/examples/servers/everything/pyproject.toml b/examples/servers/everything/pyproject.toml index c4c7e2c94..e79b4fdf1 100644 --- a/examples/servers/everything/pyproject.toml +++ b/examples/servers/everything/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "mcp-server-everything" version = "0.1.0" -description = "Comprehensive MCP server demonstrating all protocol features" +description = "Latest spec MCP server demonstrating all protocol features" readme = "README.md" requires-python = ">=3.10" dependencies = [ diff --git a/examples/servers/everything/src/everything/__main__.py b/examples/servers/everything/src/everything/__main__.py index 409dd0b28..8dc2f328e 100644 --- a/examples/servers/everything/src/everything/__main__.py +++ b/examples/servers/everything/src/everything/__main__.py @@ -6,9 +6,8 @@ def main(): """Run the everything server.""" mcp = create_everything_server() - # Use FastMCP's built-in run method for better CLI integration - mcp.run(transport="sse") + mcp.run(transport="streamable-http") if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/examples/servers/everything/src/everything/server.py b/examples/servers/everything/src/everything/server.py index 0a61e1ff6..01fe07b69 100644 --- a/examples/servers/everything/src/everything/server.py +++ b/examples/servers/everything/src/everything/server.py @@ -1,15 +1,9 @@ """ -Everything Server - Comprehensive MCP FastMCP Server -Shows all MCP features including tools, resources, prompts, sampling, and more. - +Everything Server This example demonstrates the 2025-06-18 protocol features including: -- Tools with progress reporting, logging, and elicitation +- Tools with progress reporting, logging, elicitation and sampling - Resource handling (static, dynamic, and templated) -- Prompts with arguments -- Completion support for resource templates and prompts -- Request context propagation -- Notifications and logging -- Sampling capabilities +- Prompts with arguments and completions """ import json @@ -252,4 +246,4 @@ class AlternativeDateSchema(BaseModel): # Available - book directly return f"✅ Booked table for {party_size} on {date} at {time}" - return mcp \ No newline at end of file + return mcp From da0c986a48f198fb2f9f1485463ee86c25992efb Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 29 Jun 2025 22:52:32 +0100 Subject: [PATCH 04/25] security suggestions --- .github/workflows/shared.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index adf5dbbaf..1f211bd19 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -3,6 +3,9 @@ name: Shared Checks on: workflow_call: +permissions: + contents: read + jobs: pre-commit: runs-on: ubuntu-latest From 6e5b666526647a91fcde1b67e29e576ce2140b55 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 29 Jun 2025 22:56:55 +0100 Subject: [PATCH 05/25] CI fixes --- README.md | 8 +- .../everything/src/everything/__init__.py | 2 +- .../everything/src/everything/server.py | 4 +- scripts/update_readme_snippets.py | 85 ++++++++----------- tests/server/fastmcp/test_integration.py | 2 - 5 files changed, 44 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index d0fa696f0..81d8c7033 100644 --- a/README.md +++ b/README.md @@ -419,10 +419,12 @@ def create_thumbnail(image_path: str) -> Image: The Context object gives your tools and resources access to MCP capabilities: - + ```python # Tool with context for logging and progress - @mcp.tool(description="A tool that demonstrates logging and progress", title="Progress Tool") + @mcp.tool( + description="A tool that demonstrates logging and progress", title="Progress Tool" + ) async def tool_with_progress(message: str, ctx: Context, steps: int = 3) -> str: await ctx.info(f"Starting processing of '{message}' with {steps} steps") @@ -438,7 +440,7 @@ The Context object gives your tools and resources access to MCP capabilities: return f"Processed '{message}' in {steps} steps" ``` -_Full example: [examples/servers/everything/src/everything/server.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/servers/everything/src/everything/server.py#L43-L58)_ +_Full example: [examples/servers/everything/src/everything/server.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/servers/everything/src/everything/server.py#L37-L54)_ ### Completions diff --git a/examples/servers/everything/src/everything/__init__.py b/examples/servers/everything/src/everything/__init__.py index 7769dfb21..62f087dfe 100644 --- a/examples/servers/everything/src/everything/__init__.py +++ b/examples/servers/everything/src/everything/__init__.py @@ -2,4 +2,4 @@ from .server import create_everything_server -__all__ = ["create_everything_server"] \ No newline at end of file +__all__ = ["create_everything_server"] diff --git a/examples/servers/everything/src/everything/server.py b/examples/servers/everything/src/everything/server.py index 01fe07b69..ca00afa6b 100644 --- a/examples/servers/everything/src/everything/server.py +++ b/examples/servers/everything/src/everything/server.py @@ -35,7 +35,9 @@ def create_everything_server() -> FastMCP: mcp = FastMCP(name="EverythingServer", transport_security=transport_security) # Tool with context for logging and progress - @mcp.tool(description="A tool that demonstrates logging and progress", title="Progress Tool") + @mcp.tool( + description="A tool that demonstrates logging and progress", title="Progress Tool" + ) async def tool_with_progress(message: str, ctx: Context, steps: int = 3) -> str: await ctx.info(f"Starting processing of '{message}' with {steps} steps") diff --git a/scripts/update_readme_snippets.py b/scripts/update_readme_snippets.py index 8cf49c909..9655de6c7 100755 --- a/scripts/update_readme_snippets.py +++ b/scripts/update_readme_snippets.py @@ -18,12 +18,12 @@ def extract_lines(file_path: Path, start_line: int, end_line: int) -> str: """Extract lines from a file. - + Args: file_path: Path to the file start_line: Starting line number (1-based) end_line: Ending line number (1-based, inclusive) - + Returns: The extracted lines """ @@ -32,38 +32,38 @@ def extract_lines(file_path: Path, start_line: int, end_line: int) -> str: # Convert to 0-based indexing start_idx = max(0, start_line - 1) end_idx = min(len(lines), end_line) - return '\n'.join(lines[start_idx:end_idx]) + return "\n".join(lines[start_idx:end_idx]) def get_github_url(file_path: str, start_line: int, end_line: int) -> str: """Generate a GitHub URL for the file with line highlighting. - + Args: file_path: Path to the file relative to repo root start_line: Starting line number end_line: Ending line number - + Returns: GitHub URL with line highlighting """ base_url = "https://github.com/modelcontextprotocol/python-sdk/blob/main" url = f"{base_url}/{file_path}" - + if start_line == end_line: url += f"#L{start_line}" else: url += f"#L{start_line}-L{end_line}" - + return url def process_snippet_block(match: re.Match, check_mode: bool = False) -> str: """Process a single snippet-source block. - + Args: match: The regex match object check_mode: If True, return original if no changes needed - + Returns: The updated block content """ @@ -72,19 +72,19 @@ def process_snippet_block(match: re.Match, check_mode: bool = False) -> str: file_path = match.group(2) start_line = int(match.group(3)) end_line = int(match.group(4)) - + try: # Read and extract the code file = Path(file_path) if not file.exists(): print(f"Warning: File not found: {file_path}") return full_match - + code = extract_lines(file, start_line, end_line) - + # Generate GitHub URL github_url = get_github_url(file_path, start_line, end_line) - + # Build the replacement block replacement = f"""{indent} {indent}```python @@ -92,29 +92,29 @@ def process_snippet_block(match: re.Match, check_mode: bool = False) -> str: {indent}``` {indent}_Full example: [{file_path}]({github_url})_ {indent}""" - + # In check mode, only check if code has changed if check_mode: # Extract existing code from the match existing_content = match.group(5) if existing_content is not None: - existing_lines = existing_content.strip().split('\n') + existing_lines = existing_content.strip().split("\n") # Find code between ```python and ``` code_lines = [] in_code = False for line in existing_lines: - if line.strip() == '```python': + if line.strip() == "```python": in_code = True - elif line.strip() == '```': + elif line.strip() == "```": break elif in_code: code_lines.append(line) - existing_code = '\n'.join(code_lines).strip() + existing_code = "\n".join(code_lines).strip() if existing_code == code.strip(): return full_match - + return replacement - + except Exception as e: print(f"Error processing {file_path}#L{start_line}-L{end_line}: {e}") return full_match @@ -122,39 +122,32 @@ def process_snippet_block(match: re.Match, check_mode: bool = False) -> str: def update_readme_snippets(readme_path: Path = Path("README.md"), check_mode: bool = False) -> bool: """Update code snippets in README.md with live code from source files. - + Args: readme_path: Path to the README file check_mode: If True, only check if updates are needed without modifying - + Returns: True if file is up to date or was updated, False if check failed """ if not readme_path.exists(): print(f"Error: README file not found: {readme_path}") return False - + content = readme_path.read_text() original_content = content - + # Pattern to match snippet-source blocks with line ranges # Matches: # ... any content ... # - pattern = ( - r'^(\s*)\n' - r'(.*?)' - r'^\1' - ) - + pattern = r"^(\s*)\n" r"(.*?)" r"^\1" + # Process all snippet-source blocks updated_content = re.sub( - pattern, - lambda m: process_snippet_block(m, check_mode), - content, - flags=re.MULTILINE | re.DOTALL + pattern, lambda m: process_snippet_block(m, check_mode), content, flags=re.MULTILINE | re.DOTALL ) - + if check_mode: if updated_content != original_content: print( @@ -176,27 +169,19 @@ def update_readme_snippets(readme_path: Path = Path("README.md"), check_mode: bo def main(): """Main entry point.""" - parser = argparse.ArgumentParser( - description='Update README code snippets from source files' - ) - parser.add_argument( - '--check', - action='store_true', - help='Check mode - verify snippets are up to date without modifying' - ) + parser = argparse.ArgumentParser(description="Update README code snippets from source files") parser.add_argument( - '--readme', - default='README.md', - help='Path to README file (default: README.md)' + "--check", action="store_true", help="Check mode - verify snippets are up to date without modifying" ) - + parser.add_argument("--readme", default="README.md", help="Path to README file (default: README.md)") + args = parser.parse_args() - + success = update_readme_snippets(Path(args.readme), check_mode=args.check) - + if not success: sys.exit(1) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/tests/server/fastmcp/test_integration.py b/tests/server/fastmcp/test_integration.py index be5524bfe..46882744d 100644 --- a/tests/server/fastmcp/test_integration.py +++ b/tests/server/fastmcp/test_integration.py @@ -118,8 +118,6 @@ class AnswerSchema(BaseModel): return mcp, app - - def make_everything_fastmcp_app(): """Create a comprehensive FastMCP server with SSE transport.""" mcp = create_everything_server() From d67ad08773962ec2182a776a6ab6ed0c055e8106 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 29 Jun 2025 23:03:27 +0100 Subject: [PATCH 06/25] remove readme from lint --- .pre-commit-config.yaml | 1 + examples/servers/everything/src/everything/server.py | 4 +--- pyproject.toml | 1 + 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ef7a33b74..6eb8fc4ff 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,6 +23,7 @@ repos: types: [python] language: system pass_filenames: false + exclude: ^README\.md$ - id: pyright name: pyright entry: uv run pyright diff --git a/examples/servers/everything/src/everything/server.py b/examples/servers/everything/src/everything/server.py index ca00afa6b..01fe07b69 100644 --- a/examples/servers/everything/src/everything/server.py +++ b/examples/servers/everything/src/everything/server.py @@ -35,9 +35,7 @@ def create_everything_server() -> FastMCP: mcp = FastMCP(name="EverythingServer", transport_security=transport_security) # Tool with context for logging and progress - @mcp.tool( - description="A tool that demonstrates logging and progress", title="Progress Tool" - ) + @mcp.tool(description="A tool that demonstrates logging and progress", title="Progress Tool") async def tool_with_progress(message: str, ctx: Context, steps: int = 3) -> str: await ctx.info(f"Starting processing of '{message}' with {steps} steps") diff --git a/pyproject.toml b/pyproject.toml index 9b617f667..e16a8f805 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,6 +99,7 @@ ignore = ["PERF203"] [tool.ruff] line-length = 120 target-version = "py310" +extend-exclude = ["README.md"] [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401"] From c4486f8cacc0d0c5e1801f5e129d782ef273d3ee Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 29 Jun 2025 23:08:00 +0100 Subject: [PATCH 07/25] update contributing --- CONTRIBUTING.md | 7 ++++++- README.md | 6 +++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 929e5f504..a263678a2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,7 +40,12 @@ uv run ruff check . uv run ruff format . ``` -7. Submit a pull request to the same branch you branched from +7. Update README snippets if you modified example code: +```bash +uv run scripts/update_readme_snippets.py +``` + +8. Submit a pull request to the same branch you branched from ## Code Style diff --git a/README.md b/README.md index 81d8c7033..e5709d680 100644 --- a/README.md +++ b/README.md @@ -422,9 +422,7 @@ The Context object gives your tools and resources access to MCP capabilities: ```python # Tool with context for logging and progress - @mcp.tool( - description="A tool that demonstrates logging and progress", title="Progress Tool" - ) + @mcp.tool(description="A tool that demonstrates logging and progress", title="Progress Tool") async def tool_with_progress(message: str, ctx: Context, steps: int = 3) -> str: await ctx.info(f"Starting processing of '{message}' with {steps} steps") @@ -439,6 +437,8 @@ The Context object gives your tools and resources access to MCP capabilities: await ctx.debug(f"Completed step {i + 1}") return f"Processed '{message}' in {steps} steps" + + # Simple tool for basic functionality ``` _Full example: [examples/servers/everything/src/everything/server.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/servers/everything/src/everything/server.py#L37-L54)_ From 3a7080585b8acabfec576c6b2353be99ff2d596f Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 29 Jun 2025 23:11:25 +0100 Subject: [PATCH 08/25] 120 for docs as well... --- tests/test_examples.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 230e7d394..b3420bc2d 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -84,7 +84,7 @@ async def test_desktop(monkeypatch): def test_docs_examples(example: CodeExample, eval_example: EvalExample): ruff_ignore: list[str] = ["F841", "I001"] - eval_example.set_config(ruff_ignore=ruff_ignore, target_version="py310", line_length=88) + eval_example.set_config(ruff_ignore=ruff_ignore, target_version="py310", line_length=120) if eval_example.update_examples: # pragma: no cover eval_example.format(example) From 3203841f972019e4be722321c43296393410c86e Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 29 Jun 2025 23:23:50 +0100 Subject: [PATCH 09/25] use ruff in snippets tests --- tests/test_examples.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index b3420bc2d..6a0fc55c4 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -82,11 +82,13 @@ async def test_desktop(monkeypatch): @pytest.mark.parametrize("example", find_examples("README.md"), ids=str) def test_docs_examples(example: CodeExample, eval_example: EvalExample): - ruff_ignore: list[str] = ["F841", "I001"] + ruff_ignore: list[str] = ["F841", "I001", "F821"] # F821: undefined names (snippets lack imports) + # Use project's actual line length of 120 eval_example.set_config(ruff_ignore=ruff_ignore, target_version="py310", line_length=120) + # Use Ruff for both formatting and linting (skip Black) if eval_example.update_examples: # pragma: no cover - eval_example.format(example) + eval_example.format_ruff(example) else: - eval_example.lint(example) + eval_example.lint_ruff(example) From 73676a5079152858c43579e6ec849151952e66ba Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 1 Jul 2025 14:26:49 +0100 Subject: [PATCH 10/25] use small servers --- README.md | 284 +++-- examples/snippets/pyproject.toml | 25 + examples/snippets/servers/__init__.py | 106 ++ examples/snippets/servers/basic_prompt.py | 15 + examples/snippets/servers/basic_resource.py | 20 + examples/snippets/servers/basic_tool.py | 16 + examples/snippets/servers/completion.py | 49 + examples/snippets/servers/elicitation.py | 41 + examples/snippets/servers/notifications.py | 18 + examples/snippets/servers/sampling.py | 24 + examples/snippets/servers/tool_progress.py | 20 + scripts/update_readme_snippets.py | 29 +- tests/server/fastmcp/test_integration.py | 1233 +++++++------------ 13 files changed, 987 insertions(+), 893 deletions(-) create mode 100644 examples/snippets/pyproject.toml create mode 100644 examples/snippets/servers/__init__.py create mode 100644 examples/snippets/servers/basic_prompt.py create mode 100644 examples/snippets/servers/basic_resource.py create mode 100644 examples/snippets/servers/basic_tool.py create mode 100644 examples/snippets/servers/completion.py create mode 100644 examples/snippets/servers/elicitation.py create mode 100644 examples/snippets/servers/notifications.py create mode 100644 examples/snippets/servers/sampling.py create mode 100644 examples/snippets/servers/tool_progress.py diff --git a/README.md b/README.md index e5709d680..5bc7b4dbf 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ - [Context](#context) - [Completions](#completions) - [Elicitation](#elicitation) + - [Sampling](#sampling) + - [Logging and Notifications](#logging-and-notifications) - [Authentication](#authentication) - [Running Your Server](#running-your-server) - [Development Mode](#development-mode) @@ -207,48 +209,57 @@ def query_db() -> str: Resources are how you expose data to LLMs. They're similar to GET endpoints in a REST API - they provide data but shouldn't perform significant computation or have side effects: + ```python from mcp.server.fastmcp import FastMCP -mcp = FastMCP("My App") +mcp = FastMCP(name="Resource Example") -@mcp.resource("config://app", title="Application Configuration") -def get_config() -> str: - """Static configuration data""" - return "App configuration here" +@mcp.resource("file://documents/{name}") +def read_document(name: str) -> str: + """Read a document by name.""" + # This would normally read from disk + return f"Content of {name}" -@mcp.resource("users://{user_id}/profile", title="User Profile") -def get_user_profile(user_id: str) -> str: - """Dynamic user data""" - return f"Profile data for user {user_id}" +@mcp.resource("config://settings") +def get_settings() -> str: + """Get application settings.""" + return """{ + "theme": "dark", + "language": "en", + "debug": false +}""" ``` +_Full example: [examples/snippets/servers/basic_resource.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_resource.py)_ + ### Tools Tools let LLMs take actions through your server. Unlike resources, tools are expected to perform computation and have side effects: + ```python -import httpx from mcp.server.fastmcp import FastMCP -mcp = FastMCP("My App") +mcp = FastMCP(name="Tool Example") -@mcp.tool(title="BMI Calculator") -def calculate_bmi(weight_kg: float, height_m: float) -> float: - """Calculate BMI given weight in kg and height in meters""" - return weight_kg / (height_m**2) +@mcp.tool(description="Add two numbers") +def add(a: int, b: int) -> int: + """Add two numbers together.""" + return a + b -@mcp.tool(title="Weather Fetcher") -async def fetch_weather(city: str) -> str: - """Fetch current weather for a city""" - async with httpx.AsyncClient() as client: - response = await client.get(f"https://api.weather.com/{city}") - return response.text +@mcp.tool(description="Get weather for a city") +def get_weather(city: str, unit: str = "celsius") -> str: + """Get weather for a city.""" + # This would normally call a weather API + return f"Weather in {city}: 22°{unit[0].upper()}" ``` +_Full example: [examples/snippets/servers/basic_tool.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_tool.py)_ + #### Structured Output @@ -375,26 +386,26 @@ def get_temperature(city: str) -> float: Prompts are reusable templates that help LLMs interact with your server effectively: + ```python from mcp.server.fastmcp import FastMCP -from mcp.server.fastmcp.prompts import base -mcp = FastMCP("My App") +mcp = FastMCP(name="Prompt Example") -@mcp.prompt(title="Code Review") -def review_code(code: str) -> str: - return f"Please review this code:\n\n{code}" +@mcp.prompt(description="Generate a summary") +def summarize(text: str, max_words: int = 100) -> str: + """Create a summarization prompt.""" + return f"Summarize this text in {max_words} words:\n\n{text}" -@mcp.prompt(title="Debug Assistant") -def debug_error(error: str) -> list[base.Message]: - return [ - base.UserMessage("I'm seeing this error:"), - base.UserMessage(error), - base.AssistantMessage("I'll help debug that. What have you tried so far?"), - ] +@mcp.prompt(description="Explain a concept") +def explain(concept: str, audience: str = "general") -> str: + """Create an explanation prompt.""" + return f"Explain {concept} for a {audience} audience" ``` +_Full example: [examples/snippets/servers/basic_prompt.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_prompt.py)_ + ### Images @@ -419,28 +430,30 @@ def create_thumbnail(image_path: str) -> Image: The Context object gives your tools and resources access to MCP capabilities: - + ```python - # Tool with context for logging and progress - @mcp.tool(description="A tool that demonstrates logging and progress", title="Progress Tool") - async def tool_with_progress(message: str, ctx: Context, steps: int = 3) -> str: - await ctx.info(f"Starting processing of '{message}' with {steps} steps") - - # Send progress notifications - for i in range(steps): - progress_value = (i + 1) / steps - await ctx.report_progress( - progress=progress_value, - total=1.0, - message=f"Processing step {i + 1} of {steps}", - ) - await ctx.debug(f"Completed step {i + 1}") +from mcp.server.fastmcp import Context, FastMCP + +mcp = FastMCP(name="Progress Example") - return f"Processed '{message}' in {steps} steps" - # Simple tool for basic functionality +@mcp.tool(description="Demonstrates progress reporting") +async def long_running_task(task_name: str, ctx: Context, steps: int = 5) -> str: + """Execute a task with progress updates.""" + await ctx.info(f"Starting: {task_name}") + + for i in range(steps): + progress = (i + 1) / steps + await ctx.report_progress( + progress=progress, + total=1.0, + message=f"Step {i + 1}/{steps}", + ) + await ctx.debug(f"Completed step {i + 1}") + + return f"Task '{task_name}' completed" ``` -_Full example: [examples/servers/everything/src/everything/server.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/servers/everything/src/everything/server.py#L37-L54)_ +_Full example: [examples/snippets/servers/tool_progress.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/tool_progress.py)_ ### Completions @@ -473,8 +486,10 @@ async def use_completion(session: ClientSession): ``` Server implementation: + + ```python -from mcp.server import Server +from mcp.server.fastmcp import FastMCP from mcp.types import ( Completion, CompletionArgument, @@ -483,72 +498,167 @@ from mcp.types import ( ResourceTemplateReference, ) -server = Server("example-server") +mcp = FastMCP(name="Example") + +@mcp.resource("github://repos/{owner}/{repo}") +def github_repo(owner: str, repo: str) -> str: + """GitHub repository resource.""" + return f"Repository: {owner}/{repo}" -@server.completion() + +@mcp.prompt(description="Code review prompt") +def review_code(language: str, code: str) -> str: + """Generate a code review.""" + return f"Review this {language} code:\n{code}" + + +@mcp.completion() async def handle_completion( ref: PromptReference | ResourceTemplateReference, argument: CompletionArgument, context: CompletionContext | None, ) -> Completion | None: + """Provide completions for prompts and resources.""" + + # Complete programming languages for the prompt + if isinstance(ref, PromptReference): + if ref.name == "review_code" and argument.name == "language": + languages = ["python", "javascript", "typescript", "go", "rust"] + return Completion( + values=[lang for lang in languages if lang.startswith(argument.value)], + hasMore=False, + ) + + # Complete repository names for GitHub resources if isinstance(ref, ResourceTemplateReference): if ref.uri == "github://repos/{owner}/{repo}" and argument.name == "repo": - # Use context to provide owner-specific repos - if context and context.arguments: - owner = context.arguments.get("owner") - if owner == "modelcontextprotocol": - repos = ["python-sdk", "typescript-sdk", "specification"] - # Filter based on partial input - filtered = [r for r in repos if r.startswith(argument.value)] - return Completion(values=filtered) + if context and context.arguments and context.arguments.get("owner") == "modelcontextprotocol": + repos = ["python-sdk", "typescript-sdk", "specification"] + return Completion(values=repos, hasMore=False) + return None ``` +_Full example: [examples/snippets/servers/completion.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/completion.py)_ + ### Elicitation Request additional information from users during tool execution: + ```python -from mcp.server.fastmcp import FastMCP, Context -from mcp.server.elicitation import ( - AcceptedElicitation, - DeclinedElicitation, - CancelledElicitation, -) from pydantic import BaseModel, Field -mcp = FastMCP("Booking System") +from mcp.server.fastmcp import Context, FastMCP +mcp = FastMCP(name="Elicitation Example") -@mcp.tool() -async def book_table(date: str, party_size: int, ctx: Context) -> str: - """Book a table with confirmation""" - # Schema must only contain primitive types (str, int, float, bool) - class ConfirmBooking(BaseModel): - confirm: bool = Field(description="Confirm booking?") - notes: str = Field(default="", description="Special requests") +class BookingPreferences(BaseModel): + """Schema for collecting user preferences.""" - result = await ctx.elicit( - message=f"Confirm booking for {party_size} on {date}?", schema=ConfirmBooking + checkAlternative: bool = Field(description="Would you like to check another date?") + alternativeDate: str = Field( + default="2024-12-26", + description="Alternative date (YYYY-MM-DD)", ) - match result: - case AcceptedElicitation(data=data): - if data.confirm: - return f"Booked! Notes: {data.notes or 'None'}" - return "Booking cancelled" - case DeclinedElicitation(): - return "Booking declined" - case CancelledElicitation(): - return "Booking cancelled" + +@mcp.tool(description="Book a restaurant table") +async def book_table( + date: str, + time: str, + party_size: int, + ctx: Context, +) -> str: + """Book a table with date availability check.""" + # Check if date is available + if date == "2024-12-25": + # Date unavailable - ask user for alternative + result = await ctx.elicit( + message=(f"No tables available for {party_size} on {date}. " "Would you like to try another date?"), + schema=BookingPreferences, + ) + + if result.action == "accept" and result.data: + if result.data.checkAlternative: + return f"✅ Booked for {result.data.alternativeDate}" + return "❌ No booking made" + return "❌ Booking cancelled" + + # Date available + return f"✅ Booked for {date} at {time}" ``` +_Full example: [examples/snippets/servers/elicitation.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/elicitation.py)_ + The `elicit()` method returns an `ElicitationResult` with: - `action`: "accept", "decline", or "cancel" - `data`: The validated response (only when accepted) - `validation_error`: Any validation error message +### Sampling + +Tools can interact with LLMs through sampling (generating text): + + +```python +from mcp.server.fastmcp import Context, FastMCP +from mcp.types import SamplingMessage, TextContent + +mcp = FastMCP(name="Sampling Example") + + +@mcp.tool(description="Uses sampling to generate content") +async def generate_poem(topic: str, ctx: Context) -> str: + """Generate a poem using LLM sampling.""" + prompt = f"Write a short poem about {topic}" + + result = await ctx.session.create_message( + messages=[ + SamplingMessage( + role="user", + content=TextContent(type="text", text=prompt), + ) + ], + max_tokens=100, + ) + + if result.content.type == "text": + return result.content.text + return str(result.content) +``` +_Full example: [examples/snippets/servers/sampling.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/sampling.py)_ + + +### Logging and Notifications + +Tools can send logs and notifications through the context: + + +```python +from mcp.server.fastmcp import Context, FastMCP + +mcp = FastMCP(name="Notifications Example") + + +@mcp.tool(description="Demonstrates logging at different levels") +async def process_data(data: str, ctx: Context) -> str: + """Process data with comprehensive logging.""" + # Different log levels + await ctx.debug(f"Debug: Processing '{data}'") + await ctx.info("Info: Starting processing") + await ctx.warning("Warning: This is experimental") + await ctx.error("Error: (This is just a demo)") + + # Notify about resource changes + await ctx.session.send_resource_list_changed() + + return f"Processed: {data}" +``` +_Full example: [examples/snippets/servers/notifications.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/notifications.py)_ + + ### Authentication Authentication can be used by servers that want to expose tools accessing protected resources. diff --git a/examples/snippets/pyproject.toml b/examples/snippets/pyproject.toml new file mode 100644 index 000000000..3445669b7 --- /dev/null +++ b/examples/snippets/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "mcp-snippets" +version = "0.1.0" +description = "MCP Example Snippets" +requires-python = ">=3.10" +dependencies = [ + "mcp", +] + +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["servers"] + +[project.scripts] +basic-tool = "servers:run_basic_tool" +basic-resource = "servers:run_basic_resource" +basic-prompt = "servers:run_basic_prompt" +tool-progress = "servers:run_tool_progress" +sampling = "servers:run_sampling" +elicitation = "servers:run_elicitation" +completion = "servers:run_completion" +notifications = "servers:run_notifications" \ No newline at end of file diff --git a/examples/snippets/servers/__init__.py b/examples/snippets/servers/__init__.py new file mode 100644 index 000000000..3adc7add8 --- /dev/null +++ b/examples/snippets/servers/__init__.py @@ -0,0 +1,106 @@ +"""MCP Snippets. + +This package contains simple examples of MCP server features. +Each server demonstrates a single feature and can be run as a standalone server. +""" + +import sys +from typing import Literal + + +def run_server(module_name: str, transport: Literal["stdio", "sse", "streamable-http"] | None = None) -> None: + """Run a snippet server with the specified transport. + + Args: + module_name: Name of the snippet module to run + transport: Transport to use (stdio, sse, streamable-http). + If None, uses first command line arg or defaults to stdio. + """ + # Import the specific module based on name + if module_name == "basic_tool": + from . import basic_tool + + mcp = basic_tool.mcp + elif module_name == "basic_resource": + from . import basic_resource + + mcp = basic_resource.mcp + elif module_name == "basic_prompt": + from . import basic_prompt + + mcp = basic_prompt.mcp + elif module_name == "tool_progress": + from . import tool_progress + + mcp = tool_progress.mcp + elif module_name == "sampling": + from . import sampling + + mcp = sampling.mcp + elif module_name == "elicitation": + from . import elicitation + + mcp = elicitation.mcp + elif module_name == "completion": + from . import completion + + mcp = completion.mcp + elif module_name == "notifications": + from . import notifications + + mcp = notifications.mcp + else: + raise ValueError(f"Unknown module: {module_name}") + + # Determine transport + if transport is None: + transport_arg = sys.argv[1] if len(sys.argv) > 1 else "stdio" + # Validate and cast transport + if transport_arg not in ["stdio", "sse", "streamable-http"]: + raise ValueError(f"Invalid transport: {transport_arg}. Must be one of: stdio, sse, streamable-http") + transport = transport_arg # type: ignore + + # Run the server + print(f"Starting {module_name} server with {transport} transport...") + mcp.run(transport=transport) # type: ignore + + +# Entry points for each snippet +def run_basic_tool(): + """Run the basic tool example server.""" + run_server("basic_tool") + + +def run_basic_resource(): + """Run the basic resource example server.""" + run_server("basic_resource") + + +def run_basic_prompt(): + """Run the basic prompt example server.""" + run_server("basic_prompt") + + +def run_tool_progress(): + """Run the tool progress example server.""" + run_server("tool_progress") + + +def run_sampling(): + """Run the sampling example server.""" + run_server("sampling") + + +def run_elicitation(): + """Run the elicitation example server.""" + run_server("elicitation") + + +def run_completion(): + """Run the completion example server.""" + run_server("completion") + + +def run_notifications(): + """Run the notifications example server.""" + run_server("notifications") diff --git a/examples/snippets/servers/basic_prompt.py b/examples/snippets/servers/basic_prompt.py new file mode 100644 index 000000000..c4af39e28 --- /dev/null +++ b/examples/snippets/servers/basic_prompt.py @@ -0,0 +1,15 @@ +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP(name="Prompt Example") + + +@mcp.prompt(description="Generate a summary") +def summarize(text: str, max_words: int = 100) -> str: + """Create a summarization prompt.""" + return f"Summarize this text in {max_words} words:\n\n{text}" + + +@mcp.prompt(description="Explain a concept") +def explain(concept: str, audience: str = "general") -> str: + """Create an explanation prompt.""" + return f"Explain {concept} for a {audience} audience" diff --git a/examples/snippets/servers/basic_resource.py b/examples/snippets/servers/basic_resource.py new file mode 100644 index 000000000..5c1973059 --- /dev/null +++ b/examples/snippets/servers/basic_resource.py @@ -0,0 +1,20 @@ +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP(name="Resource Example") + + +@mcp.resource("file://documents/{name}") +def read_document(name: str) -> str: + """Read a document by name.""" + # This would normally read from disk + return f"Content of {name}" + + +@mcp.resource("config://settings") +def get_settings() -> str: + """Get application settings.""" + return """{ + "theme": "dark", + "language": "en", + "debug": false +}""" diff --git a/examples/snippets/servers/basic_tool.py b/examples/snippets/servers/basic_tool.py new file mode 100644 index 000000000..3bc4df270 --- /dev/null +++ b/examples/snippets/servers/basic_tool.py @@ -0,0 +1,16 @@ +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP(name="Tool Example") + + +@mcp.tool(description="Add two numbers") +def add(a: int, b: int) -> int: + """Add two numbers together.""" + return a + b + + +@mcp.tool(description="Get weather for a city") +def get_weather(city: str, unit: str = "celsius") -> str: + """Get weather for a city.""" + # This would normally call a weather API + return f"Weather in {city}: 22°{unit[0].upper()}" diff --git a/examples/snippets/servers/completion.py b/examples/snippets/servers/completion.py new file mode 100644 index 000000000..2a31541dd --- /dev/null +++ b/examples/snippets/servers/completion.py @@ -0,0 +1,49 @@ +from mcp.server.fastmcp import FastMCP +from mcp.types import ( + Completion, + CompletionArgument, + CompletionContext, + PromptReference, + ResourceTemplateReference, +) + +mcp = FastMCP(name="Example") + + +@mcp.resource("github://repos/{owner}/{repo}") +def github_repo(owner: str, repo: str) -> str: + """GitHub repository resource.""" + return f"Repository: {owner}/{repo}" + + +@mcp.prompt(description="Code review prompt") +def review_code(language: str, code: str) -> str: + """Generate a code review.""" + return f"Review this {language} code:\n{code}" + + +@mcp.completion() +async def handle_completion( + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, +) -> Completion | None: + """Provide completions for prompts and resources.""" + + # Complete programming languages for the prompt + if isinstance(ref, PromptReference): + if ref.name == "review_code" and argument.name == "language": + languages = ["python", "javascript", "typescript", "go", "rust"] + return Completion( + values=[lang for lang in languages if lang.startswith(argument.value)], + hasMore=False, + ) + + # Complete repository names for GitHub resources + if isinstance(ref, ResourceTemplateReference): + if ref.uri == "github://repos/{owner}/{repo}" and argument.name == "repo": + if context and context.arguments and context.arguments.get("owner") == "modelcontextprotocol": + repos = ["python-sdk", "typescript-sdk", "specification"] + return Completion(values=repos, hasMore=False) + + return None diff --git a/examples/snippets/servers/elicitation.py b/examples/snippets/servers/elicitation.py new file mode 100644 index 000000000..1977981d3 --- /dev/null +++ b/examples/snippets/servers/elicitation.py @@ -0,0 +1,41 @@ +from pydantic import BaseModel, Field + +from mcp.server.fastmcp import Context, FastMCP + +mcp = FastMCP(name="Elicitation Example") + + +class BookingPreferences(BaseModel): + """Schema for collecting user preferences.""" + + checkAlternative: bool = Field(description="Would you like to check another date?") + alternativeDate: str = Field( + default="2024-12-26", + description="Alternative date (YYYY-MM-DD)", + ) + + +@mcp.tool(description="Book a restaurant table") +async def book_table( + date: str, + time: str, + party_size: int, + ctx: Context, +) -> str: + """Book a table with date availability check.""" + # Check if date is available + if date == "2024-12-25": + # Date unavailable - ask user for alternative + result = await ctx.elicit( + message=(f"No tables available for {party_size} on {date}. " "Would you like to try another date?"), + schema=BookingPreferences, + ) + + if result.action == "accept" and result.data: + if result.data.checkAlternative: + return f"✅ Booked for {result.data.alternativeDate}" + return "❌ No booking made" + return "❌ Booking cancelled" + + # Date available + return f"✅ Booked for {date} at {time}" diff --git a/examples/snippets/servers/notifications.py b/examples/snippets/servers/notifications.py new file mode 100644 index 000000000..9467a071a --- /dev/null +++ b/examples/snippets/servers/notifications.py @@ -0,0 +1,18 @@ +from mcp.server.fastmcp import Context, FastMCP + +mcp = FastMCP(name="Notifications Example") + + +@mcp.tool(description="Demonstrates logging at different levels") +async def process_data(data: str, ctx: Context) -> str: + """Process data with comprehensive logging.""" + # Different log levels + await ctx.debug(f"Debug: Processing '{data}'") + await ctx.info("Info: Starting processing") + await ctx.warning("Warning: This is experimental") + await ctx.error("Error: (This is just a demo)") + + # Notify about resource changes + await ctx.session.send_resource_list_changed() + + return f"Processed: {data}" diff --git a/examples/snippets/servers/sampling.py b/examples/snippets/servers/sampling.py new file mode 100644 index 000000000..ad6e64249 --- /dev/null +++ b/examples/snippets/servers/sampling.py @@ -0,0 +1,24 @@ +from mcp.server.fastmcp import Context, FastMCP +from mcp.types import SamplingMessage, TextContent + +mcp = FastMCP(name="Sampling Example") + + +@mcp.tool(description="Uses sampling to generate content") +async def generate_poem(topic: str, ctx: Context) -> str: + """Generate a poem using LLM sampling.""" + prompt = f"Write a short poem about {topic}" + + result = await ctx.session.create_message( + messages=[ + SamplingMessage( + role="user", + content=TextContent(type="text", text=prompt), + ) + ], + max_tokens=100, + ) + + if result.content.type == "text": + return result.content.text + return str(result.content) diff --git a/examples/snippets/servers/tool_progress.py b/examples/snippets/servers/tool_progress.py new file mode 100644 index 000000000..171556876 --- /dev/null +++ b/examples/snippets/servers/tool_progress.py @@ -0,0 +1,20 @@ +from mcp.server.fastmcp import Context, FastMCP + +mcp = FastMCP(name="Progress Example") + + +@mcp.tool(description="Demonstrates progress reporting") +async def long_running_task(task_name: str, ctx: Context, steps: int = 5) -> str: + """Execute a task with progress updates.""" + await ctx.info(f"Starting: {task_name}") + + for i in range(steps): + progress = (i + 1) / steps + await ctx.report_progress( + progress=progress, + total=1.0, + message=f"Step {i + 1}/{steps}", + ) + await ctx.debug(f"Completed step {i + 1}") + + return f"Task '{task_name}' completed" diff --git a/scripts/update_readme_snippets.py b/scripts/update_readme_snippets.py index 9655de6c7..fce7be387 100755 --- a/scripts/update_readme_snippets.py +++ b/scripts/update_readme_snippets.py @@ -70,8 +70,8 @@ def process_snippet_block(match: re.Match, check_mode: bool = False) -> str: full_match = match.group(0) indent = match.group(1) file_path = match.group(2) - start_line = int(match.group(3)) - end_line = int(match.group(4)) + start_line = match.group(3) # May be None + end_line = match.group(4) # May be None try: # Read and extract the code @@ -80,13 +80,21 @@ def process_snippet_block(match: re.Match, check_mode: bool = False) -> str: print(f"Warning: File not found: {file_path}") return full_match - code = extract_lines(file, start_line, end_line) - - # Generate GitHub URL - github_url = get_github_url(file_path, start_line, end_line) + if start_line and end_line: + # Line range specified + start_line = int(start_line) + end_line = int(end_line) + code = extract_lines(file, start_line, end_line) + github_url = get_github_url(file_path, start_line, end_line) + line_ref = f"#L{start_line}-L{end_line}" + else: + # No line range - use whole file + code = file.read_text().rstrip() + github_url = f"https://github.com/modelcontextprotocol/python-sdk/blob/main/{file_path}" + line_ref = "" # Build the replacement block - replacement = f"""{indent} + replacement = f"""{indent} {indent}```python {code} {indent}``` @@ -137,11 +145,12 @@ def update_readme_snippets(readme_path: Path = Path("README.md"), check_mode: bo content = readme_path.read_text() original_content = content - # Pattern to match snippet-source blocks with line ranges - # Matches: + # Pattern to match snippet-source blocks with optional line ranges + # Matches: + # or: # ... any content ... # - pattern = r"^(\s*)\n" r"(.*?)" r"^\1" + pattern = r"^(\s*)\n" r"(.*?)" r"^\1" # Process all snippet-source blocks updated_content = re.sub( diff --git a/tests/server/fastmcp/test_integration.py b/tests/server/fastmcp/test_integration.py index 46882744d..8fa1ac0a3 100644 --- a/tests/server/fastmcp/test_integration.py +++ b/tests/server/fastmcp/test_integration.py @@ -1,8 +1,8 @@ """ Integration tests for FastMCP server functionality. -These tests validate the proper functioning of FastMCP in various configurations, -including with and without authentication. +These tests validate the proper functioning of FastMCP features using focused, +single-feature servers across different transports (SSE and StreamableHTTP). """ import json @@ -13,29 +13,30 @@ import pytest import uvicorn -from pydantic import AnyUrl, BaseModel, Field -from starlette.applications import Starlette - -from examples.servers.everything.src.everything import create_everything_server +from pydantic import AnyUrl + +from examples.snippets.servers import ( + basic_prompt, + basic_resource, + basic_tool, + completion, + elicitation, + notifications, + sampling, + tool_progress, +) from mcp.client.session import ClientSession from mcp.client.sse import sse_client from mcp.client.streamable_http import streamablehttp_client -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.transport_security import TransportSecuritySettings -from mcp.shared.context import RequestContext from mcp.types import ( - CreateMessageRequestParams, CreateMessageResult, ElicitResult, GetPromptResult, InitializeResult, LoggingMessageNotification, ProgressNotification, - PromptReference, ReadResourceResult, - ResourceLink, ResourceListChangedNotification, - ResourceTemplateReference, ServerNotification, TextContent, TextResourceContents, @@ -43,6 +44,29 @@ ) +class NotificationCollector: + """Collects notifications from the server for testing.""" + + def __init__(self): + self.progress_notifications: list = [] + self.log_messages: list = [] + self.resource_notifications: list = [] + self.tool_notifications: list = [] + + async def handle_generic_notification(self, message) -> None: + """Handle any server notification and route to appropriate handler.""" + if isinstance(message, ServerNotification): + if isinstance(message.root, ProgressNotification): + self.progress_notifications.append(message.root.params) + elif isinstance(message.root, LoggingMessageNotification): + self.log_messages.append(message.root.params) + elif isinstance(message.root, ResourceListChangedNotification): + self.resource_notifications.append(message.root.params) + elif isinstance(message.root, ToolListChangedNotification): + self.tool_notifications.append(message.root.params) + + +# Common fixtures @pytest.fixture def server_port() -> int: """Get a free port for testing.""" @@ -57,170 +81,64 @@ def server_url(server_port: int) -> str: return f"http://127.0.0.1:{server_port}" -@pytest.fixture -def http_server_port() -> int: - """Get a free port for testing the StreamableHTTP server.""" - with socket.socket() as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] - - -@pytest.fixture -def http_server_url(http_server_port: int) -> str: - """Get the StreamableHTTP server URL for testing.""" - return f"http://127.0.0.1:{http_server_port}" +def run_server_with_transport(module_name: str, port: int, transport: str) -> None: + """Run server with specified transport.""" + # Get the MCP instance based on module name + if module_name == "basic_tool": + mcp = basic_tool.mcp + elif module_name == "basic_resource": + mcp = basic_resource.mcp + elif module_name == "basic_prompt": + mcp = basic_prompt.mcp + elif module_name == "tool_progress": + mcp = tool_progress.mcp + elif module_name == "sampling": + mcp = sampling.mcp + elif module_name == "elicitation": + mcp = elicitation.mcp + elif module_name == "completion": + mcp = completion.mcp + elif module_name == "notifications": + mcp = notifications.mcp + else: + raise ImportError(f"Unknown module: {module_name}") + # Create app based on transport type + if transport == "sse": + app = mcp.sse_app() + elif transport == "streamable-http": + app = mcp.streamable_http_app() + else: + raise ValueError(f"Invalid transport for test server: {transport}") -@pytest.fixture -def stateless_http_server_port() -> int: - """Get a free port for testing the stateless StreamableHTTP server.""" - with socket.socket() as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] + server = uvicorn.Server(config=uvicorn.Config(app=app, host="127.0.0.1", port=port, log_level="error")) + print(f"Starting {transport} server on port {port}") + server.run() @pytest.fixture -def stateless_http_server_url(stateless_http_server_port: int) -> str: - """Get the stateless StreamableHTTP server URL for testing.""" - return f"http://127.0.0.1:{stateless_http_server_port}" - - -# Create a function to make the FastMCP server app -def make_fastmcp_app(): - """Create a FastMCP server without auth settings.""" - transport_security = TransportSecuritySettings( - allowed_hosts=["127.0.0.1:*", "localhost:*"], allowed_origins=["http://127.0.0.1:*", "http://localhost:*"] - ) - mcp = FastMCP(name="NoAuthServer", transport_security=transport_security) - - # Add a simple tool - @mcp.tool(description="A simple echo tool") - def echo(message: str) -> str: - return f"Echo: {message}" - - # Add a tool that uses elicitation - @mcp.tool(description="A tool that uses elicitation") - async def ask_user(prompt: str, ctx: Context) -> str: - class AnswerSchema(BaseModel): - answer: str = Field(description="The user's answer to the question") - - result = await ctx.elicit(message=f"Tool wants to ask: {prompt}", schema=AnswerSchema) - - if result.action == "accept" and result.data: - return f"User answered: {result.data.answer}" - else: - # Handle cancellation or decline - return f"User cancelled or declined: {result.action}" - - # Create the SSE app - app = mcp.sse_app() - - return mcp, app - - -def make_everything_fastmcp_app(): - """Create a comprehensive FastMCP server with SSE transport.""" - mcp = create_everything_server() - # Create the SSE app - app = mcp.sse_app() - return mcp, app +def server_transport(request, server_port: int) -> Generator[str, None, None]: + """Start server in a separate process with specified MCP instance and transport. + Args: + request: pytest request with param tuple of (module_name, transport) + server_port: Port to run the server on -def make_fastmcp_streamable_http_app(): - """Create a FastMCP server with StreamableHTTP transport.""" - transport_security = TransportSecuritySettings( - allowed_hosts=["127.0.0.1:*", "localhost:*"], allowed_origins=["http://127.0.0.1:*", "http://localhost:*"] - ) - mcp = FastMCP(name="NoAuthServer", transport_security=transport_security) - - # Add a simple tool - @mcp.tool(description="A simple echo tool") - def echo(message: str) -> str: - return f"Echo: {message}" - - # Create the StreamableHTTP app - app: Starlette = mcp.streamable_http_app() - - return mcp, app - - -def make_everything_fastmcp_streamable_http_app(): - """Create a comprehensive FastMCP server with StreamableHTTP transport.""" - mcp = create_everything_server() - # Create the StreamableHTTP app - app: Starlette = mcp.streamable_http_app() - return mcp, app - + Yields: + str: The transport type ('sse' or 'streamable_http') + """ + module_name, transport = request.param -def make_fastmcp_stateless_http_app(): - """Create a FastMCP server with stateless StreamableHTTP transport.""" - transport_security = TransportSecuritySettings( - allowed_hosts=["127.0.0.1:*", "localhost:*"], allowed_origins=["http://127.0.0.1:*", "http://localhost:*"] + proc = multiprocessing.Process( + target=run_server_with_transport, + args=(module_name, server_port, transport), + daemon=True, ) - mcp = FastMCP(name="StatelessServer", stateless_http=True, transport_security=transport_security) - - # Add a simple tool - @mcp.tool(description="A simple echo tool") - def echo(message: str) -> str: - return f"Echo: {message}" - - # Create the StreamableHTTP app - app: Starlette = mcp.streamable_http_app() - - return mcp, app - - -def run_server(server_port: int) -> None: - """Run the server.""" - _, app = make_fastmcp_app() - server = uvicorn.Server(config=uvicorn.Config(app=app, host="127.0.0.1", port=server_port, log_level="error")) - print(f"Starting server on port {server_port}") - server.run() - - -def run_everything_legacy_sse_http_server(server_port: int) -> None: - """Run the comprehensive server with all features.""" - _, app = make_everything_fastmcp_app() - server = uvicorn.Server(config=uvicorn.Config(app=app, host="127.0.0.1", port=server_port, log_level="error")) - print(f"Starting comprehensive server on port {server_port}") - server.run() - - -def run_streamable_http_server(server_port: int) -> None: - """Run the StreamableHTTP server.""" - _, app = make_fastmcp_streamable_http_app() - server = uvicorn.Server(config=uvicorn.Config(app=app, host="127.0.0.1", port=server_port, log_level="error")) - print(f"Starting StreamableHTTP server on port {server_port}") - server.run() - - -def run_everything_server(server_port: int) -> None: - """Run the comprehensive StreamableHTTP server with all features.""" - _, app = make_everything_fastmcp_streamable_http_app() - server = uvicorn.Server(config=uvicorn.Config(app=app, host="127.0.0.1", port=server_port, log_level="error")) - print(f"Starting comprehensive StreamableHTTP server on port {server_port}") - server.run() - - -def run_stateless_http_server(server_port: int) -> None: - """Run the stateless StreamableHTTP server.""" - _, app = make_fastmcp_stateless_http_app() - server = uvicorn.Server(config=uvicorn.Config(app=app, host="127.0.0.1", port=server_port, log_level="error")) - print(f"Starting stateless StreamableHTTP server on port {server_port}") - server.run() - - -@pytest.fixture() -def server(server_port: int) -> Generator[None, None, None]: - """Start the server in a separate process and clean up after the test.""" - proc = multiprocessing.Process(target=run_server, args=(server_port,), daemon=True) - print("Starting server process") proc.start() # Wait for server to be running max_attempts = 20 attempt = 0 - print("Waiting for server to start") while attempt < max_attempts: try: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: @@ -232,708 +150,431 @@ def server(server_port: int) -> Generator[None, None, None]: else: raise RuntimeError(f"Server failed to start after {max_attempts} attempts") - yield + yield transport - print("Killing server") proc.kill() proc.join(timeout=2) if proc.is_alive(): print("Server process failed to terminate") -@pytest.fixture() -def streamable_http_server(http_server_port: int) -> Generator[None, None, None]: - """Start the StreamableHTTP server in a separate process.""" - proc = multiprocessing.Process(target=run_streamable_http_server, args=(http_server_port,), daemon=True) - print("Starting StreamableHTTP server process") - proc.start() - - # Wait for server to be running - max_attempts = 20 - attempt = 0 - print("Waiting for StreamableHTTP server to start") - while attempt < max_attempts: - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.connect(("127.0.0.1", http_server_port)) - break - except ConnectionRefusedError: - time.sleep(0.1) - attempt += 1 +# Helper function to create client based on transport +def create_client_for_transport(transport: str, server_url: str): + """Create the appropriate client context manager based on transport type.""" + if transport == "sse": + endpoint = f"{server_url}/sse" + return sse_client(endpoint) + elif transport == "streamable-http": + endpoint = f"{server_url}/mcp" + return streamablehttp_client(endpoint) else: - raise RuntimeError(f"StreamableHTTP server failed to start after {max_attempts} attempts") + raise ValueError(f"Invalid transport: {transport}") - yield - print("Killing StreamableHTTP server") - proc.kill() - proc.join(timeout=2) - if proc.is_alive(): - print("StreamableHTTP server process failed to terminate") +def unpack_streams(client_streams): + """Unpack client streams handling different return values from SSE vs StreamableHTTP. + SSE client returns (read_stream, write_stream) + StreamableHTTP client returns (read_stream, write_stream, session_id_callback) -@pytest.fixture() -def stateless_http_server( - stateless_http_server_port: int, -) -> Generator[None, None, None]: - """Start the stateless StreamableHTTP server in a separate process.""" - proc = multiprocessing.Process( - target=run_stateless_http_server, - args=(stateless_http_server_port,), - daemon=True, - ) - print("Starting stateless StreamableHTTP server process") - proc.start() + Args: + client_streams: Tuple from client context manager - # Wait for server to be running - max_attempts = 20 - attempt = 0 - print("Waiting for stateless StreamableHTTP server to start") - while attempt < max_attempts: - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.connect(("127.0.0.1", stateless_http_server_port)) - break - except ConnectionRefusedError: - time.sleep(0.1) - attempt += 1 + Returns: + Tuple of (read_stream, write_stream) + """ + if len(client_streams) == 2: + return client_streams else: - raise RuntimeError(f"Stateless server failed to start after {max_attempts} attempts") + read_stream, write_stream, _ = client_streams + return read_stream, write_stream - yield - print("Killing stateless StreamableHTTP server") - proc.kill() - proc.join(timeout=2) - if proc.is_alive(): - print("Stateless StreamableHTTP server process failed to terminate") +# Callback functions for testing +async def sampling_callback(context, params) -> CreateMessageResult: + """Sampling callback for tests.""" + return CreateMessageResult( + role="assistant", + content=TextContent( + type="text", + text="This is a simulated LLM response for testing", + ), + model="test-model", + ) +async def elicitation_callback(context, params): + """Elicitation callback for tests.""" + # For restaurant booking test + if "No tables available" in params.message: + return ElicitResult( + action="accept", + content={"checkAlternative": True, "alternativeDate": "2024-12-26"}, + ) + else: + return ElicitResult(action="decline") + + +# Test basic tools @pytest.mark.anyio -async def test_fastmcp_without_auth(server: None, server_url: str) -> None: - """Test that FastMCP works when auth settings are not provided.""" - # Connect to the server - async with sse_client(server_url + "/sse") as streams: - async with ClientSession(*streams) as session: +@pytest.mark.parametrize( + "server_transport", + [ + ("basic_tool", "sse"), + ("basic_tool", "streamable-http"), + ], + indirect=True, +) +async def test_basic_tools(server_transport: str, server_url: str) -> None: + """Test basic tool functionality.""" + transport = server_transport + client_cm = create_client_for_transport(transport, server_url) + + async with client_cm as client_streams: + read_stream, write_stream = unpack_streams(client_streams) + async with ClientSession(read_stream, write_stream) as session: # Test initialization result = await session.initialize() assert isinstance(result, InitializeResult) - assert result.serverInfo.name == "NoAuthServer" + assert result.serverInfo.name == "Basic Tool Example" + assert result.capabilities.tools is not None - # Test that we can call tools without authentication - tool_result = await session.call_tool("echo", {"message": "hello"}) + # Test add tool + tool_result = await session.call_tool("add", {"a": 5, "b": 3}) assert len(tool_result.content) == 1 assert isinstance(tool_result.content[0], TextContent) - assert tool_result.content[0].text == "Echo: hello" + assert tool_result.content[0].text == "8" + + # Test weather tool + weather_result = await session.call_tool("get_weather", {"city": "London"}) + assert len(weather_result.content) == 1 + assert isinstance(weather_result.content[0], TextContent) + assert "Weather in London: 22°C" in weather_result.content[0].text +# Test resources @pytest.mark.anyio -async def test_fastmcp_streamable_http(streamable_http_server: None, http_server_url: str) -> None: - """Test that FastMCP works with StreamableHTTP transport.""" - # Connect to the server using StreamableHTTP - async with streamablehttp_client(http_server_url + "/mcp") as ( - read_stream, - write_stream, - _, - ): - # Create a session using the client streams +@pytest.mark.parametrize( + "server_transport", + [ + ("basic_resource", "sse"), + ("basic_resource", "streamable-http"), + ], + indirect=True, +) +async def test_basic_resources(server_transport: str, server_url: str) -> None: + """Test basic resource functionality.""" + transport = server_transport + client_cm = create_client_for_transport(transport, server_url) + + async with client_cm as client_streams: + read_stream, write_stream = unpack_streams(client_streams) async with ClientSession(read_stream, write_stream) as session: # Test initialization result = await session.initialize() assert isinstance(result, InitializeResult) - assert result.serverInfo.name == "NoAuthServer" - - # Test that we can call tools without authentication - tool_result = await session.call_tool("echo", {"message": "hello"}) - assert len(tool_result.content) == 1 - assert isinstance(tool_result.content[0], TextContent) - assert tool_result.content[0].text == "Echo: hello" - - + assert result.serverInfo.name == "Basic Resource Example" + assert result.capabilities.resources is not None + + # Test document resource + doc_content = await session.read_resource(AnyUrl("file://documents/readme")) + assert isinstance(doc_content, ReadResourceResult) + assert len(doc_content.contents) == 1 + assert isinstance(doc_content.contents[0], TextResourceContents) + assert "Content of readme" in doc_content.contents[0].text + + # Test settings resource + settings_content = await session.read_resource(AnyUrl("config://settings")) + assert isinstance(settings_content, ReadResourceResult) + assert len(settings_content.contents) == 1 + assert isinstance(settings_content.contents[0], TextResourceContents) + settings_json = json.loads(settings_content.contents[0].text) + assert settings_json["theme"] == "dark" + assert settings_json["language"] == "en" + + +# Test prompts @pytest.mark.anyio -async def test_fastmcp_stateless_streamable_http(stateless_http_server: None, stateless_http_server_url: str) -> None: - """Test that FastMCP works with stateless StreamableHTTP transport.""" - # Connect to the server using StreamableHTTP - async with streamablehttp_client(stateless_http_server_url + "/mcp") as ( - read_stream, - write_stream, - _, - ): +@pytest.mark.parametrize( + "server_transport", + [ + ("basic_prompt", "sse"), + ("basic_prompt", "streamable-http"), + ], + indirect=True, +) +async def test_basic_prompts(server_transport: str, server_url: str) -> None: + """Test basic prompt functionality.""" + transport = server_transport + client_cm = create_client_for_transport(transport, server_url) + + async with client_cm as client_streams: + read_stream, write_stream = unpack_streams(client_streams) async with ClientSession(read_stream, write_stream) as session: + # Test initialization result = await session.initialize() assert isinstance(result, InitializeResult) - assert result.serverInfo.name == "StatelessServer" - tool_result = await session.call_tool("echo", {"message": "hello"}) - assert len(tool_result.content) == 1 - assert isinstance(tool_result.content[0], TextContent) - assert tool_result.content[0].text == "Echo: hello" - - for i in range(3): - tool_result = await session.call_tool("echo", {"message": f"test_{i}"}) - assert len(tool_result.content) == 1 - assert isinstance(tool_result.content[0], TextContent) - assert tool_result.content[0].text == f"Echo: test_{i}" - - -@pytest.fixture -def everything_server_port() -> int: - """Get a free port for testing the comprehensive server.""" - with socket.socket() as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] - - -@pytest.fixture -def everything_server_url(everything_server_port: int) -> str: - """Get the comprehensive server URL for testing.""" - return f"http://127.0.0.1:{everything_server_port}" - - -@pytest.fixture -def everything_http_server_port() -> int: - """Get a free port for testing the comprehensive StreamableHTTP server.""" - with socket.socket() as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] - - -@pytest.fixture -def everything_http_server_url(everything_http_server_port: int) -> str: - """Get the comprehensive StreamableHTTP server URL for testing.""" - return f"http://127.0.0.1:{everything_http_server_port}" - - -@pytest.fixture() -def everything_server(everything_server_port: int) -> Generator[None, None, None]: - """Start the comprehensive server in a separate process and clean up after.""" - proc = multiprocessing.Process( - target=run_everything_legacy_sse_http_server, - args=(everything_server_port,), - daemon=True, - ) - print("Starting comprehensive server process") - proc.start() - - # Wait for server to be running - max_attempts = 20 - attempt = 0 - print("Waiting for comprehensive server to start") - while attempt < max_attempts: - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.connect(("127.0.0.1", everything_server_port)) - break - except ConnectionRefusedError: - time.sleep(0.1) - attempt += 1 - else: - raise RuntimeError(f"Comprehensive server failed to start after {max_attempts} attempts") + assert result.serverInfo.name == "Basic Prompt Example" + assert result.capabilities.prompts is not None - yield - - print("Killing comprehensive server") - proc.kill() - proc.join(timeout=2) - if proc.is_alive(): - print("Comprehensive server process failed to terminate") - - -@pytest.fixture() -def everything_streamable_http_server( - everything_http_server_port: int, -) -> Generator[None, None, None]: - """Start the comprehensive StreamableHTTP server in a separate process.""" - proc = multiprocessing.Process( - target=run_everything_server, - args=(everything_http_server_port,), - daemon=True, - ) - print("Starting comprehensive StreamableHTTP server process") - proc.start() - - # Wait for server to be running - max_attempts = 20 - attempt = 0 - print("Waiting for comprehensive StreamableHTTP server to start") - while attempt < max_attempts: - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.connect(("127.0.0.1", everything_http_server_port)) - break - except ConnectionRefusedError: - time.sleep(0.1) - attempt += 1 - else: - raise RuntimeError(f"Comprehensive StreamableHTTP server failed to start after " f"{max_attempts} attempts") + # Test summarize prompt + prompts = await session.list_prompts() + summarize_prompt = next((p for p in prompts.prompts if p.name == "summarize"), None) + assert summarize_prompt is not None - yield + prompt_result = await session.get_prompt("summarize", {"text": "Long text here", "max_words": "50"}) + assert isinstance(prompt_result, GetPromptResult) + assert len(prompt_result.messages) >= 1 - print("Killing comprehensive StreamableHTTP server") - proc.kill() - proc.join(timeout=2) - if proc.is_alive(): - print("Comprehensive StreamableHTTP server process failed to terminate") + # Test explain prompt + explain_result = await session.get_prompt( + "explain", {"concept": "machine learning", "audience": "beginner"} + ) + assert isinstance(explain_result, GetPromptResult) + assert len(explain_result.messages) >= 1 -class NotificationCollector: - def __init__(self): - self.progress_notifications: list = [] - self.log_messages: list = [] - self.resource_notifications: list = [] - self.tool_notifications: list = [] - - async def handle_progress(self, params) -> None: - self.progress_notifications.append(params) - - async def handle_log(self, params) -> None: - self.log_messages.append(params) +# Test progress reporting +@pytest.mark.anyio +@pytest.mark.parametrize( + "server_transport", + [ + ("tool_progress", "sse"), + ("tool_progress", "streamable-http"), + ], + indirect=True, +) +async def test_tool_progress(server_transport: str, server_url: str) -> None: + """Test tool progress reporting.""" + transport = server_transport + collector = NotificationCollector() - async def handle_resource_list_changed(self, params) -> None: - self.resource_notifications.append(params) + async def message_handler(message): + await collector.handle_generic_notification(message) + if isinstance(message, Exception): + raise message - async def handle_tool_list_changed(self, params) -> None: - self.tool_notifications.append(params) + client_cm = create_client_for_transport(transport, server_url) - async def handle_generic_notification(self, message) -> None: - # Check if this is a ServerNotification - if isinstance(message, ServerNotification): - # Check the specific notification type - if isinstance(message.root, ProgressNotification): - await self.handle_progress(message.root.params) - elif isinstance(message.root, LoggingMessageNotification): - await self.handle_log(message.root.params) - elif isinstance(message.root, ResourceListChangedNotification): - await self.handle_resource_list_changed(message.root.params) - elif isinstance(message.root, ToolListChangedNotification): - await self.handle_tool_list_changed(message.root.params) - - -async def create_test_elicitation_callback(context, params): - """Shared elicitation callback for tests. + async with client_cm as client_streams: + read_stream, write_stream = unpack_streams(client_streams) + async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session: + # Test initialization + result = await session.initialize() + assert isinstance(result, InitializeResult) + assert result.serverInfo.name == "Progress Example" - Handles elicitation requests for restaurant booking tests. - """ - # For restaurant booking test - if "No tables available" in params.message: - return ElicitResult( - action="accept", - content={"checkAlternative": True, "alternativeDate": "2024-12-26"}, - ) - else: - # Default response - return ElicitResult(action="decline") + # Test progress callback + progress_updates = [] + async def progress_callback(progress: float, total: float | None, message: str | None) -> None: + progress_updates.append((progress, total, message)) -async def call_all_mcp_features(session: ClientSession, collector: NotificationCollector) -> None: - """ - Test all MCP features using the provided session. + # Call tool with progress + steps = 3 + tool_result = await session.call_tool( + "long_running_task", + {"task_name": "Test Task", "steps": steps}, + progress_callback=progress_callback, + ) - Args: - session: The MCP client session to test with - collector: Notification collector for capturing server notifications - """ - # Test initialization - result = await session.initialize() - assert isinstance(result, InitializeResult) - assert result.serverInfo.name == "EverythingServer" - - # Check server features are reported - assert result.capabilities.prompts is not None - assert result.capabilities.resources is not None - assert result.capabilities.tools is not None - # Note: logging capability may be None if no tools use context logging - - # Test tools - # 1. Simple echo tool - tool_result = await session.call_tool("echo", {"message": "hello"}) - assert len(tool_result.content) == 1 - assert isinstance(tool_result.content[0], TextContent) - assert tool_result.content[0].text == "Echo: hello" - - # 2. Test tool that returns ResourceLinks - list_files_result = await session.call_tool("list_files") - assert len(list_files_result.content) == 1 - - # Rest should be ResourceLinks - content = list_files_result.content[0] - assert isinstance(content, ResourceLink) - assert str(content.uri).startswith("file:///") - assert content.name is not None - assert content.mimeType is not None - - # Test progress callback functionality - progress_updates = [] - - async def progress_callback(progress: float, total: float | None, message: str | None) -> None: - """Collect progress updates for testing (async version).""" - progress_updates.append((progress, total, message)) - print(f"Progress: {progress}/{total} - {message}") - - test_message = "test" - steps = 3 - params = { - "message": test_message, - "steps": steps, - } - tool_result = await session.call_tool( - "tool_with_progress", - params, - progress_callback=progress_callback, - ) - assert len(tool_result.content) == 1 - assert isinstance(tool_result.content[0], TextContent) - assert f"Processed '{test_message}' in {steps} steps" in tool_result.content[0].text - - # Verify progress callback was called - assert len(progress_updates) == steps - for i, (progress, total, message) in enumerate(progress_updates): - expected_progress = (i + 1) / steps - assert abs(progress - expected_progress) < 0.01 - assert total == 1.0 - assert message is not None - assert f"step {i + 1} of {steps}" in message - - # Verify we received log messages from the tool - # Note: Progress notifications require special handling in the MCP client - # that's not implemented by default, so we focus on testing logging - assert len(collector.log_messages) > 0 - - # 3. Test sampling tool - prompt = "What is the meaning of life?" - sampling_result = await session.call_tool("sampling_tool", {"prompt": prompt}) - assert len(sampling_result.content) == 1 - assert isinstance(sampling_result.content[0], TextContent) - assert "Sampling result:" in sampling_result.content[0].text - assert "This is a simulated LLM response" in sampling_result.content[0].text - - # Verify we received log messages from the sampling tool - assert len(collector.log_messages) > 0 - assert any("Requesting sampling for prompt" in msg.data for msg in collector.log_messages) - assert any("Received sampling result from model" in msg.data for msg in collector.log_messages) - - # 4. Test notification tool - notification_message = "test_notifications" - notification_result = await session.call_tool("notification_tool", {"message": notification_message}) - assert len(notification_result.content) == 1 - assert isinstance(notification_result.content[0], TextContent) - assert "Sent notifications and logs" in notification_result.content[0].text - - # Verify we received various notification types - assert len(collector.log_messages) > 3 # Should have logs from both tools - assert len(collector.resource_notifications) > 0 - assert len(collector.tool_notifications) > 0 - - # Check that we got different log levels - log_levels = [msg.level for msg in collector.log_messages] - assert "debug" in log_levels - assert "info" in log_levels - assert "warning" in log_levels - - # 5. Test elicitation tool - # Test restaurant booking with unavailable date (triggers elicitation) - booking_result = await session.call_tool( - "book_restaurant", - { - "date": "2024-12-25", # Unavailable date to trigger elicitation - "time": "19:00", - "party_size": 4, - }, - ) - assert len(booking_result.content) == 1 - assert isinstance(booking_result.content[0], TextContent) - # Should have booked the alternative date from elicitation callback - assert "✅ Booked table for 4 on 2024-12-26" in booking_result.content[0].text - - # Test resources - # 1. Static resource - resources = await session.list_resources() - # Try using string comparison since AnyUrl might not match directly - static_resource = next( - (r for r in resources.resources if str(r.uri) == "resource://static/info"), - None, - ) - assert static_resource is not None - assert static_resource.name == "Static Info" - - static_content = await session.read_resource(AnyUrl("resource://static/info")) - assert isinstance(static_content, ReadResourceResult) - assert len(static_content.contents) == 1 - assert isinstance(static_content.contents[0], TextResourceContents) - assert static_content.contents[0].text == "This is static resource content" - - # 2. Dynamic resource - resource_category = "test" - dynamic_content = await session.read_resource(AnyUrl(f"resource://dynamic/{resource_category}")) - assert isinstance(dynamic_content, ReadResourceResult) - assert len(dynamic_content.contents) == 1 - assert isinstance(dynamic_content.contents[0], TextResourceContents) - assert f"Dynamic resource content for category: {resource_category}" in dynamic_content.contents[0].text - - # 3. Template resource - resource_id = "456" - template_content = await session.read_resource(AnyUrl(f"resource://template/{resource_id}/data")) - assert isinstance(template_content, ReadResourceResult) - assert len(template_content.contents) == 1 - assert isinstance(template_content.contents[0], TextResourceContents) - assert f"Template resource data for ID: {resource_id}" in template_content.contents[0].text - - # Test prompts - # 1. Simple prompt - prompts = await session.list_prompts() - simple_prompt = next((p for p in prompts.prompts if p.name == "simple_prompt"), None) - assert simple_prompt is not None - - prompt_topic = "AI" - prompt_result = await session.get_prompt("simple_prompt", {"topic": prompt_topic}) - assert isinstance(prompt_result, GetPromptResult) - assert len(prompt_result.messages) >= 1 - # The actual message structure depends on the prompt implementation - - # 2. Complex prompt - complex_prompt = next((p for p in prompts.prompts if p.name == "complex_prompt"), None) - assert complex_prompt is not None - - query = "What is AI?" - context = "technical" - complex_result = await session.get_prompt("complex_prompt", {"user_query": query, "context": context}) - assert isinstance(complex_result, GetPromptResult) - assert len(complex_result.messages) >= 1 - - # Test request context propagation (only works when headers are available) - - headers_result = await session.call_tool("echo_headers", {}) - assert len(headers_result.content) == 1 - assert isinstance(headers_result.content[0], TextContent) - - # If we got headers, verify they exist - headers_data = json.loads(headers_result.content[0].text) - # The headers depend on the transport and test setup - print(f"Received headers: {headers_data}") - - # Test 6: Call tool that returns full context - context_result = await session.call_tool("echo_context", {"custom_request_id": "test-123"}) - assert len(context_result.content) == 1 - assert isinstance(context_result.content[0], TextContent) - - context_data = json.loads(context_result.content[0].text) - assert context_data["custom_request_id"] == "test-123" - # The method should be POST for most transports - if context_data["method"]: - assert context_data["method"] == "POST" - - # Test completion functionality - # 1. Test resource template completion with context - repo_result = await session.complete( - ref=ResourceTemplateReference(type="ref/resource", uri="github://repos/{owner}/{repo}"), - argument={"name": "repo", "value": ""}, - context_arguments={"owner": "modelcontextprotocol"}, - ) - assert repo_result.completion.values == ["python-sdk", "typescript-sdk", "specification"] - assert repo_result.completion.total == 3 - assert repo_result.completion.hasMore is False - - # 2. Test with different context - repo_result2 = await session.complete( - ref=ResourceTemplateReference(type="ref/resource", uri="github://repos/{owner}/{repo}"), - argument={"name": "repo", "value": ""}, - context_arguments={"owner": "test-org"}, - ) - assert repo_result2.completion.values == ["test-repo1", "test-repo2"] - assert repo_result2.completion.total == 2 + assert len(tool_result.content) == 1 + assert isinstance(tool_result.content[0], TextContent) + assert "Task 'Test Task' completed" in tool_result.content[0].text - # 3. Test prompt argument completion - context_result = await session.complete( - ref=PromptReference(type="ref/prompt", name="complex_prompt"), - argument={"name": "context", "value": "tech"}, - ) - assert "technical" in context_result.completion.values + # Verify progress updates + assert len(progress_updates) == steps + for i, (progress, total, message) in enumerate(progress_updates): + expected_progress = (i + 1) / steps + assert abs(progress - expected_progress) < 0.01 + assert total == 1.0 + assert f"Step {i + 1}/{steps}" in message - # 4. Test completion without context (should return empty) - no_context_result = await session.complete( - ref=ResourceTemplateReference(type="ref/resource", uri="github://repos/{owner}/{repo}"), - argument={"name": "repo", "value": "test"}, - ) - assert no_context_result.completion.values == [] - assert no_context_result.completion.total == 0 - - -async def sampling_callback( - context: RequestContext[ClientSession, None], - params: CreateMessageRequestParams, -) -> CreateMessageResult: - # Simulate LLM response based on the input - if params.messages and isinstance(params.messages[0].content, TextContent): - input_text = params.messages[0].content.text - else: - input_text = "No input" - response_text = f"This is a simulated LLM response to: {input_text}" - - model_name = "test-llm-model" - return CreateMessageResult( - role="assistant", - content=TextContent(type="text", text=response_text), - model=model_name, - stopReason="endTurn", - ) + # Verify log messages + assert len(collector.log_messages) > 0 +# Test sampling @pytest.mark.anyio -async def test_fastmcp_all_features_sse(everything_server: None, everything_server_url: str) -> None: - """Test all MCP features work correctly with SSE transport.""" - - # Create notification collector - collector = NotificationCollector() +@pytest.mark.parametrize( + "server_transport", + [ + ("sampling", "sse"), + ("sampling", "streamable-http"), + ], + indirect=True, +) +async def test_sampling(server_transport: str, server_url: str) -> None: + """Test sampling (LLM interaction) functionality.""" + transport = server_transport + client_cm = create_client_for_transport(transport, server_url) + + async with client_cm as client_streams: + read_stream, write_stream = unpack_streams(client_streams) + async with ClientSession(read_stream, write_stream, sampling_callback=sampling_callback) as session: + # Test initialization + result = await session.initialize() + assert isinstance(result, InitializeResult) + assert result.serverInfo.name == "Sampling Example" + assert result.capabilities.tools is not None - # Connect to the server with callbacks - async with sse_client(everything_server_url + "/sse") as streams: - # Set up message handler to capture notifications - async def message_handler(message): - print(f"Received message: {message}") - await collector.handle_generic_notification(message) - if isinstance(message, Exception): - raise message - - async with ClientSession( - *streams, - sampling_callback=sampling_callback, - elicitation_callback=create_test_elicitation_callback, - message_handler=message_handler, - ) as session: - # Run the common test suite - await call_all_mcp_features(session, collector) + # Test sampling tool + sampling_result = await session.call_tool("generate_poem", {"topic": "nature"}) + assert len(sampling_result.content) == 1 + assert isinstance(sampling_result.content[0], TextContent) + assert "This is a simulated LLM response" in sampling_result.content[0].text +# Test elicitation @pytest.mark.anyio -async def test_fastmcp_all_features_streamable_http( - everything_streamable_http_server: None, everything_http_server_url: str -) -> None: - """Test all MCP features work correctly with StreamableHTTP transport.""" - - # Create notification collector +@pytest.mark.parametrize( + "server_transport", + [ + ("elicitation", "sse"), + ("elicitation", "streamable-http"), + ], + indirect=True, +) +async def test_elicitation(server_transport: str, server_url: str) -> None: + """Test elicitation (user interaction) functionality.""" + transport = server_transport + client_cm = create_client_for_transport(transport, server_url) + + async with client_cm as client_streams: + read_stream, write_stream = unpack_streams(client_streams) + async with ClientSession(read_stream, write_stream, elicitation_callback=elicitation_callback) as session: + # Test initialization + result = await session.initialize() + assert isinstance(result, InitializeResult) + assert result.serverInfo.name == "Elicitation Example" + + # Test booking with unavailable date (triggers elicitation) + booking_result = await session.call_tool( + "book_table", + { + "date": "2024-12-25", # Unavailable date + "time": "19:00", + "party_size": 4, + }, + ) + assert len(booking_result.content) == 1 + assert isinstance(booking_result.content[0], TextContent) + assert "✅ Booked for 2024-12-26" in booking_result.content[0].text + + # Test booking with available date (no elicitation) + booking_result = await session.call_tool( + "book_table", + { + "date": "2024-12-20", # Available date + "time": "20:00", + "party_size": 2, + }, + ) + assert len(booking_result.content) == 1 + assert isinstance(booking_result.content[0], TextContent) + assert "✅ Booked for 2024-12-20 at 20:00" in booking_result.content[0].text + + +# Test notifications +@pytest.mark.anyio +@pytest.mark.parametrize( + "server_transport", + [ + ("notifications", "sse"), + ("notifications", "streamable-http"), + ], + indirect=True, +) +async def test_notifications(server_transport: str, server_url: str) -> None: + """Test notifications and logging functionality.""" + transport = server_transport collector = NotificationCollector() - # Connect to the server using StreamableHTTP - async with streamablehttp_client(everything_http_server_url + "/mcp") as ( - read_stream, - write_stream, - _, - ): - # Set up message handler to capture notifications - async def message_handler(message): - print(f"Received message: {message}") - await collector.handle_generic_notification(message) - if isinstance(message, Exception): - raise message - - async with ClientSession( - read_stream, - write_stream, - sampling_callback=sampling_callback, - elicitation_callback=create_test_elicitation_callback, - message_handler=message_handler, - ) as session: - # Run the common test suite with HTTP-specific test suffix - await call_all_mcp_features(session, collector) + async def message_handler(message): + await collector.handle_generic_notification(message) + if isinstance(message, Exception): + raise message + client_cm = create_client_for_transport(transport, server_url) -@pytest.mark.anyio -async def test_elicitation_feature(server: None, server_url: str) -> None: - """Test the elicitation feature.""" - - # Create a custom handler for elicitation requests - async def elicitation_callback(context, params): - # Verify the elicitation parameters - if params.message == "Tool wants to ask: What is your name?": - return ElicitResult(content={"answer": "Test User"}, action="accept") - else: - raise ValueError("Unexpected elicitation message") - - # Connect to the server with our custom elicitation handler - async with sse_client(server_url + "/sse") as streams: - async with ClientSession(*streams, elicitation_callback=elicitation_callback) as session: - # First initialize the session + async with client_cm as client_streams: + read_stream, write_stream = unpack_streams(client_streams) + async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session: + # Test initialization result = await session.initialize() assert isinstance(result, InitializeResult) - assert result.serverInfo.name == "NoAuthServer" + assert result.serverInfo.name == "Notifications Example" - # Call the tool that uses elicitation - tool_result = await session.call_tool("ask_user", {"prompt": "What is your name?"}) - # Verify the result + # Call tool that generates notifications + tool_result = await session.call_tool("process_data", {"data": "test_data"}) assert len(tool_result.content) == 1 assert isinstance(tool_result.content[0], TextContent) - # # The test should only succeed with the successful elicitation response - assert tool_result.content[0].text == "User answered: Test User" + assert "Processed: test_data" in tool_result.content[0].text + + # Verify log messages at different levels + assert len(collector.log_messages) >= 4 + log_levels = {msg.level for msg in collector.log_messages} + assert "debug" in log_levels + assert "info" in log_levels + assert "warning" in log_levels + assert "error" in log_levels + + # Verify resource list changed notification + assert len(collector.resource_notifications) > 0 +# Test completion @pytest.mark.anyio -async def test_title_precedence(everything_server: None, everything_server_url: str) -> None: - """Test that titles are properly returned for tools, resources, and prompts.""" - from mcp.shared.metadata_utils import get_display_name +@pytest.mark.parametrize( + "server_transport", + [ + ("completion", "sse"), + ("completion", "streamable-http"), + ], + indirect=True, +) +async def test_completion(server_transport: str, server_url: str) -> None: + """Test completion (autocomplete) functionality.""" + transport = server_transport + client_cm = create_client_for_transport(transport, server_url) - async with sse_client(everything_server_url + "/sse") as streams: - async with ClientSession(*streams) as session: - # Initialize the session + async with client_cm as client_streams: + read_stream, write_stream = unpack_streams(client_streams) + async with ClientSession(read_stream, write_stream) as session: + # Test initialization result = await session.initialize() assert isinstance(result, InitializeResult) - - # Test tools have titles - tools_result = await session.list_tools() - assert tools_result.tools - - # Check specific tools have titles - tool_names_to_titles = { - "tool_with_progress": "Progress Tool", - "echo": "Echo Tool", - "sampling_tool": "Sampling Tool", - "notification_tool": "Notification Tool", - "echo_headers": "Echo Headers", - "echo_context": "Echo Context", - "book_restaurant": "Restaurant Booking", - } - - for tool in tools_result.tools: - if tool.name in tool_names_to_titles: - assert tool.title == tool_names_to_titles[tool.name] - # Test get_display_name utility - assert get_display_name(tool) == tool_names_to_titles[tool.name] - - # Test resources have titles - resources_result = await session.list_resources() - assert resources_result.resources - - # Check specific resources have titles - static_resource = next((r for r in resources_result.resources if r.name == "Static Info"), None) - assert static_resource is not None - assert static_resource.title == "Static Information" - assert get_display_name(static_resource) == "Static Information" - - # Test resource templates have titles - resource_templates = await session.list_resource_templates() - assert resource_templates.resourceTemplates - - # Check specific resource templates have titles - template_uris_to_titles = { - "resource://dynamic/{category}": "Dynamic Resource", - "resource://template/{id}/data": "Template Resource", - "github://repos/{owner}/{repo}": "GitHub Repository", - } - - for template in resource_templates.resourceTemplates: - if template.uriTemplate in template_uris_to_titles: - assert template.title == template_uris_to_titles[template.uriTemplate] - assert get_display_name(template) == template_uris_to_titles[template.uriTemplate] - - # Test prompts have titles - prompts_result = await session.list_prompts() - assert prompts_result.prompts - - # Check specific prompts have titles - prompt_names_to_titles = { - "simple_prompt": "Simple Prompt", - "complex_prompt": "Complex Prompt", - } - - for prompt in prompts_result.prompts: - if prompt.name in prompt_names_to_titles: - assert prompt.title == prompt_names_to_titles[prompt.name] - assert get_display_name(prompt) == prompt_names_to_titles[prompt.name] + assert result.serverInfo.name == "Completion Example" + assert result.capabilities.resources is not None + assert result.capabilities.prompts is not None + + # Test resource completion + from mcp.types import ResourceTemplateReference + + completion_result = await session.complete( + ref=ResourceTemplateReference(type="ref/resource", uri="github://repos/{owner}/{repo}"), + argument={"name": "repo", "value": ""}, + context_arguments={"owner": "modelcontextprotocol"}, + ) + + assert completion_result is not None + assert hasattr(completion_result, "completion") + assert completion_result.completion is not None + assert len(completion_result.completion.values) == 3 + assert "python-sdk" in completion_result.completion.values + assert "typescript-sdk" in completion_result.completion.values + assert "specification" in completion_result.completion.values + + # Test prompt completion + from mcp.types import PromptReference + + completion_result = await session.complete( + ref=PromptReference(type="ref/prompt", name="review_code"), + argument={"name": "language", "value": "py"}, + ) + + assert completion_result is not None + assert hasattr(completion_result, "completion") + assert completion_result.completion is not None + assert "python" in completion_result.completion.values + assert all(lang.startswith("py") for lang in completion_result.completion.values) From a9e81220b6ed1c9a1078c2ee9fe181e708b02561 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 1 Jul 2025 14:39:09 +0100 Subject: [PATCH 11/25] add snipets to workspace --- pyproject.toml | 2 +- uv.lock | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e16a8f805..a4de04266 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,7 +106,7 @@ extend-exclude = ["README.md"] "tests/server/fastmcp/test_func_metadata.py" = ["E501"] [tool.uv.workspace] -members = ["examples/servers/*"] +members = ["examples/servers/*", "examples/snippets"] [tool.uv.sources] mcp = { workspace = true } diff --git a/uv.lock b/uv.lock index 832d4a60d..5d21ae4c8 100644 --- a/uv.lock +++ b/uv.lock @@ -15,6 +15,7 @@ members = [ "mcp-simple-streamablehttp", "mcp-simple-streamablehttp-stateless", "mcp-simple-tool", + "mcp-snippets", ] [[package]] @@ -869,6 +870,17 @@ dev = [ { name = "ruff", specifier = ">=0.6.9" }, ] +[[package]] +name = "mcp-snippets" +version = "0.1.0" +source = { editable = "examples/snippets" } +dependencies = [ + { name = "mcp" }, +] + +[package.metadata] +requires-dist = [{ name = "mcp", editable = "." }] + [[package]] name = "mdurl" version = "0.1.2" From 16ac8ebb3507b85cad511949523df51f422c8ac4 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 1 Jul 2025 14:51:27 +0100 Subject: [PATCH 12/25] ruff --- scripts/update_readme_snippets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/update_readme_snippets.py b/scripts/update_readme_snippets.py index fce7be387..9c8b93f52 100755 --- a/scripts/update_readme_snippets.py +++ b/scripts/update_readme_snippets.py @@ -71,7 +71,7 @@ def process_snippet_block(match: re.Match, check_mode: bool = False) -> str: indent = match.group(1) file_path = match.group(2) start_line = match.group(3) # May be None - end_line = match.group(4) # May be None + end_line = match.group(4) # May be None try: # Read and extract the code From 6c8011b59f2faec04f5b105c2f7d143de2cf118c Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 1 Jul 2025 15:42:07 +0100 Subject: [PATCH 13/25] update server names in tests --- tests/server/fastmcp/test_integration.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/server/fastmcp/test_integration.py b/tests/server/fastmcp/test_integration.py index 8fa1ac0a3..fd88ac826 100644 --- a/tests/server/fastmcp/test_integration.py +++ b/tests/server/fastmcp/test_integration.py @@ -236,7 +236,7 @@ async def test_basic_tools(server_transport: str, server_url: str) -> None: # Test initialization result = await session.initialize() assert isinstance(result, InitializeResult) - assert result.serverInfo.name == "Basic Tool Example" + assert result.serverInfo.name == "Tool Example" assert result.capabilities.tools is not None # Test add tool @@ -273,7 +273,7 @@ async def test_basic_resources(server_transport: str, server_url: str) -> None: # Test initialization result = await session.initialize() assert isinstance(result, InitializeResult) - assert result.serverInfo.name == "Basic Resource Example" + assert result.serverInfo.name == "Resource Example" assert result.capabilities.resources is not None # Test document resource @@ -314,7 +314,7 @@ async def test_basic_prompts(server_transport: str, server_url: str) -> None: # Test initialization result = await session.initialize() assert isinstance(result, InitializeResult) - assert result.serverInfo.name == "Basic Prompt Example" + assert result.serverInfo.name == "Prompt Example" assert result.capabilities.prompts is not None # Test summarize prompt @@ -544,7 +544,7 @@ async def test_completion(server_transport: str, server_url: str) -> None: # Test initialization result = await session.initialize() assert isinstance(result, InitializeResult) - assert result.serverInfo.name == "Completion Example" + assert result.serverInfo.name == "Example" assert result.capabilities.resources is not None assert result.capabilities.prompts is not None From b318929097be655901f24ff06bf746e274ca3d03 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 1 Jul 2025 19:54:36 +0100 Subject: [PATCH 14/25] remove characters --- README.md | 2 +- examples/fastmcp/weather_structured.py | 2 +- examples/snippets/servers/basic_tool.py | 2 +- tests/server/fastmcp/test_integration.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5bc7b4dbf..d636e855b 100644 --- a/README.md +++ b/README.md @@ -256,7 +256,7 @@ def add(a: int, b: int) -> int: def get_weather(city: str, unit: str = "celsius") -> str: """Get weather for a city.""" # This would normally call a weather API - return f"Weather in {city}: 22°{unit[0].upper()}" + return f"Weather in {city}: 22degrees{unit[0].upper()}" ``` _Full example: [examples/snippets/servers/basic_tool.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_tool.py)_ diff --git a/examples/fastmcp/weather_structured.py b/examples/fastmcp/weather_structured.py index 8c26fc39e..2b24d4791 100644 --- a/examples/fastmcp/weather_structured.py +++ b/examples/fastmcp/weather_structured.py @@ -90,7 +90,7 @@ def get_weather_alerts(region: str) -> list[WeatherAlert]: WeatherAlert( severity="high", title="Heat Wave Warning", - description="Temperatures expected to exceed 40°C", + description="Temperatures expected to exceed 40degreesC", affected_areas=["Los Angeles", "San Diego", "Riverside"], valid_until=datetime(2024, 7, 15, 18, 0), ), diff --git a/examples/snippets/servers/basic_tool.py b/examples/snippets/servers/basic_tool.py index 3bc4df270..22f020c39 100644 --- a/examples/snippets/servers/basic_tool.py +++ b/examples/snippets/servers/basic_tool.py @@ -13,4 +13,4 @@ def add(a: int, b: int) -> int: def get_weather(city: str, unit: str = "celsius") -> str: """Get weather for a city.""" # This would normally call a weather API - return f"Weather in {city}: 22°{unit[0].upper()}" + return f"Weather in {city}: 22degrees{unit[0].upper()}" diff --git a/tests/server/fastmcp/test_integration.py b/tests/server/fastmcp/test_integration.py index fd88ac826..c5e45033d 100644 --- a/tests/server/fastmcp/test_integration.py +++ b/tests/server/fastmcp/test_integration.py @@ -249,7 +249,7 @@ async def test_basic_tools(server_transport: str, server_url: str) -> None: weather_result = await session.call_tool("get_weather", {"city": "London"}) assert len(weather_result.content) == 1 assert isinstance(weather_result.content[0], TextContent) - assert "Weather in London: 22°C" in weather_result.content[0].text + assert "Weather in London: 22degreesC" in weather_result.content[0].text # Test resources From 745780471aaf067a6ec6d602cf8d82dc3187d873 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 1 Jul 2025 23:11:55 +0100 Subject: [PATCH 15/25] replace chars --- README.md | 8 ++++---- examples/snippets/servers/elicitation.py | 8 ++++---- tests/server/fastmcp/test_integration.py | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index d636e855b..6ca85075d 100644 --- a/README.md +++ b/README.md @@ -582,12 +582,12 @@ async def book_table( if result.action == "accept" and result.data: if result.data.checkAlternative: - return f"✅ Booked for {result.data.alternativeDate}" - return "❌ No booking made" - return "❌ Booking cancelled" + return f"[SUCCESS] Booked for {result.data.alternativeDate}" + return "[CANCELLED] No booking made" + return "[CANCELLED] Booking cancelled" # Date available - return f"✅ Booked for {date} at {time}" + return f"[SUCCESS] Booked for {date} at {time}" ``` _Full example: [examples/snippets/servers/elicitation.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/elicitation.py)_ diff --git a/examples/snippets/servers/elicitation.py b/examples/snippets/servers/elicitation.py index 1977981d3..add3c9daf 100644 --- a/examples/snippets/servers/elicitation.py +++ b/examples/snippets/servers/elicitation.py @@ -33,9 +33,9 @@ async def book_table( if result.action == "accept" and result.data: if result.data.checkAlternative: - return f"✅ Booked for {result.data.alternativeDate}" - return "❌ No booking made" - return "❌ Booking cancelled" + return f"[SUCCESS] Booked for {result.data.alternativeDate}" + return "[CANCELLED] No booking made" + return "[CANCELLED] Booking cancelled" # Date available - return f"✅ Booked for {date} at {time}" + return f"[SUCCESS] Booked for {date} at {time}" diff --git a/tests/server/fastmcp/test_integration.py b/tests/server/fastmcp/test_integration.py index c5e45033d..e69175554 100644 --- a/tests/server/fastmcp/test_integration.py +++ b/tests/server/fastmcp/test_integration.py @@ -459,7 +459,7 @@ async def test_elicitation(server_transport: str, server_url: str) -> None: ) assert len(booking_result.content) == 1 assert isinstance(booking_result.content[0], TextContent) - assert "✅ Booked for 2024-12-26" in booking_result.content[0].text + assert "[SUCCESS] Booked for 2024-12-26" in booking_result.content[0].text # Test booking with available date (no elicitation) booking_result = await session.call_tool( @@ -472,7 +472,7 @@ async def test_elicitation(server_transport: str, server_url: str) -> None: ) assert len(booking_result.content) == 1 assert isinstance(booking_result.content[0], TextContent) - assert "✅ Booked for 2024-12-20 at 20:00" in booking_result.content[0].text + assert "[SUCCESS] Booked for 2024-12-20 at 20:00" in booking_result.content[0].text # Test notifications From d1604b182b48aa53801a840938736f402b028573 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 2 Jul 2025 11:08:19 +0100 Subject: [PATCH 16/25] remove everything server --- examples/fastmcp/weather_structured.py | 2 +- examples/servers/everything/README.md | 15 -- examples/servers/everything/pyproject.toml | 18 -- .../everything/src/everything/__init__.py | 5 - .../everything/src/everything/__main__.py | 13 - .../everything/src/everything/server.py | 249 ------------------ examples/snippets/servers/basic_prompt.py | 19 +- tests/server/fastmcp/test_integration.py | 34 ++- uv.lock | 18 -- 9 files changed, 35 insertions(+), 338 deletions(-) delete mode 100644 examples/servers/everything/README.md delete mode 100644 examples/servers/everything/pyproject.toml delete mode 100644 examples/servers/everything/src/everything/__init__.py delete mode 100644 examples/servers/everything/src/everything/__main__.py delete mode 100644 examples/servers/everything/src/everything/server.py diff --git a/examples/fastmcp/weather_structured.py b/examples/fastmcp/weather_structured.py index 2b24d4791..20cbf7957 100644 --- a/examples/fastmcp/weather_structured.py +++ b/examples/fastmcp/weather_structured.py @@ -90,7 +90,7 @@ def get_weather_alerts(region: str) -> list[WeatherAlert]: WeatherAlert( severity="high", title="Heat Wave Warning", - description="Temperatures expected to exceed 40degreesC", + description="Temperatures expected to exceed 40 degrees", affected_areas=["Los Angeles", "San Diego", "Riverside"], valid_until=datetime(2024, 7, 15, 18, 0), ), diff --git a/examples/servers/everything/README.md b/examples/servers/everything/README.md deleted file mode 100644 index 392e5ec1e..000000000 --- a/examples/servers/everything/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# MCP Everything Server - -MCP server that demonstrates all features of the Model Context Protocol, including: - -- Tools with progress reporting, logging, and elicitation and sampling, structured output -- Resource handling (static, dynamic, and templated) -- Prompts with arguments and completions - -## Usage - -### Running the server - -```bash -uv run mcp-server-everything -``` diff --git a/examples/servers/everything/pyproject.toml b/examples/servers/everything/pyproject.toml deleted file mode 100644 index e79b4fdf1..000000000 --- a/examples/servers/everything/pyproject.toml +++ /dev/null @@ -1,18 +0,0 @@ -[project] -name = "mcp-server-everything" -version = "0.1.0" -description = "Latest spec MCP server demonstrating all protocol features" -readme = "README.md" -requires-python = ">=3.10" -dependencies = [ - "mcp", - "pydantic>=2.0", - "httpx", -] - -[project.scripts] -mcp-server-everything = "everything.__main__:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" \ No newline at end of file diff --git a/examples/servers/everything/src/everything/__init__.py b/examples/servers/everything/src/everything/__init__.py deleted file mode 100644 index 62f087dfe..000000000 --- a/examples/servers/everything/src/everything/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""MCP Everything Server - Comprehensive example demonstrating all MCP features.""" - -from .server import create_everything_server - -__all__ = ["create_everything_server"] diff --git a/examples/servers/everything/src/everything/__main__.py b/examples/servers/everything/src/everything/__main__.py deleted file mode 100644 index 8dc2f328e..000000000 --- a/examples/servers/everything/src/everything/__main__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Main entry point for the Everything Server.""" - -from .server import create_everything_server - - -def main(): - """Run the everything server.""" - mcp = create_everything_server() - mcp.run(transport="streamable-http") - - -if __name__ == "__main__": - main() diff --git a/examples/servers/everything/src/everything/server.py b/examples/servers/everything/src/everything/server.py deleted file mode 100644 index 01fe07b69..000000000 --- a/examples/servers/everything/src/everything/server.py +++ /dev/null @@ -1,249 +0,0 @@ -""" -Everything Server -This example demonstrates the 2025-06-18 protocol features including: -- Tools with progress reporting, logging, elicitation and sampling -- Resource handling (static, dynamic, and templated) -- Prompts with arguments and completions -""" - -import json -from typing import Any - -from pydantic import AnyUrl, BaseModel, Field -from starlette.requests import Request - -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.fastmcp.resources import FunctionResource -from mcp.server.transport_security import TransportSecuritySettings -from mcp.types import ( - Completion, - CompletionArgument, - CompletionContext, - PromptReference, - ResourceLink, - ResourceTemplateReference, - SamplingMessage, - TextContent, -) - - -def create_everything_server() -> FastMCP: - """Create a comprehensive FastMCP server with all features enabled.""" - transport_security = TransportSecuritySettings( - allowed_hosts=["127.0.0.1:*", "localhost:*"], allowed_origins=["http://127.0.0.1:*", "http://localhost:*"] - ) - mcp = FastMCP(name="EverythingServer", transport_security=transport_security) - - # Tool with context for logging and progress - @mcp.tool(description="A tool that demonstrates logging and progress", title="Progress Tool") - async def tool_with_progress(message: str, ctx: Context, steps: int = 3) -> str: - await ctx.info(f"Starting processing of '{message}' with {steps} steps") - - # Send progress notifications - for i in range(steps): - progress_value = (i + 1) / steps - await ctx.report_progress( - progress=progress_value, - total=1.0, - message=f"Processing step {i + 1} of {steps}", - ) - await ctx.debug(f"Completed step {i + 1}") - - return f"Processed '{message}' in {steps} steps" - - # Simple tool for basic functionality - @mcp.tool(description="A simple echo tool", title="Echo Tool") - def echo(message: str) -> str: - return f"Echo: {message}" - - # Tool that returns ResourceLinks - @mcp.tool(description="Lists files and returns resource links", title="List Files Tool") - def list_files() -> list[ResourceLink]: - """Returns a list of resource links for files matching the pattern.""" - - # Sample file resources - file_resources = [ - { - "type": "resource_link", - "uri": "file:///project/README.md", - "name": "README.md", - "mimeType": "text/markdown", - } - ] - - result: list[ResourceLink] = [ResourceLink.model_validate(file_json) for file_json in file_resources] - return result - - # Tool with sampling capability - @mcp.tool(description="A tool that uses sampling to generate content", title="Sampling Tool") - async def sampling_tool(prompt: str, ctx: Context) -> str: - await ctx.info(f"Requesting sampling for prompt: {prompt}") - - # Request sampling from the client - result = await ctx.session.create_message( - messages=[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))], - max_tokens=100, - temperature=0.7, - ) - - await ctx.info(f"Received sampling result from model: {result.model}") - # Handle different content types - if result.content.type == "text": - return f"Sampling result: {result.content.text[:100]}..." - else: - return f"Sampling result: {str(result.content)[:100]}..." - - # Tool that sends notifications and logging - @mcp.tool(description="A tool that demonstrates notifications and logging", title="Notification Tool") - async def notification_tool(message: str, ctx: Context) -> str: - # Send different log levels - await ctx.debug("Debug: Starting notification tool") - await ctx.info(f"Info: Processing message '{message}'") - await ctx.warning("Warning: This is a test warning") - - # Send resource change notifications - await ctx.session.send_resource_list_changed() - await ctx.session.send_tool_list_changed() - - await ctx.info("Completed notification tool successfully") - return f"Sent notifications and logs for: {message}" - - # Resource - static - def get_static_info() -> str: - return "This is static resource content" - - static_resource = FunctionResource( - uri=AnyUrl("resource://static/info"), - name="Static Info", - title="Static Information", - description="Static information resource", - fn=get_static_info, - ) - mcp.add_resource(static_resource) - - # Resource - dynamic function - @mcp.resource("resource://dynamic/{category}", title="Dynamic Resource") - def dynamic_resource(category: str) -> str: - return f"Dynamic resource content for category: {category}" - - # Resource template - @mcp.resource("resource://template/{id}/data", title="Template Resource") - def template_resource(id: str) -> str: - return f"Template resource data for ID: {id}" - - # Prompt - simple - @mcp.prompt(description="A simple prompt", title="Simple Prompt") - def simple_prompt(topic: str) -> str: - return f"Tell me about {topic}" - - # Prompt - complex with multiple arguments - @mcp.prompt(description="Complex prompt with context", title="Complex Prompt") - def complex_prompt(user_query: str, context: str = "general") -> str: - # Return a single string that incorporates the context - return f"Context: {context}. Query: {user_query}" - - # Resource template with completion support - @mcp.resource("github://repos/{owner}/{repo}", title="GitHub Repository") - def github_repo_resource(owner: str, repo: str) -> str: - return f"Repository: {owner}/{repo}" - - # Add completion handler for the server - @mcp.completion() - async def handle_completion( - ref: PromptReference | ResourceTemplateReference, - argument: CompletionArgument, - context: CompletionContext | None, - ) -> Completion | None: - # Handle GitHub repository completion - if isinstance(ref, ResourceTemplateReference): - if ref.uri == "github://repos/{owner}/{repo}" and argument.name == "repo": - if context and context.arguments and context.arguments.get("owner") == "modelcontextprotocol": - # Return repos for modelcontextprotocol org - return Completion(values=["python-sdk", "typescript-sdk", "specification"], total=3, hasMore=False) - elif context and context.arguments and context.arguments.get("owner") == "test-org": - # Return repos for test-org - return Completion(values=["test-repo1", "test-repo2"], total=2, hasMore=False) - - # Handle prompt completions - if isinstance(ref, PromptReference): - if ref.name == "complex_prompt" and argument.name == "context": - # Complete context values - contexts = ["general", "technical", "business", "academic"] - return Completion( - values=[c for c in contexts if c.startswith(argument.value)], total=None, hasMore=False - ) - - # Default: no completion available - return Completion(values=[], total=0, hasMore=False) - - # Tool that echoes request headers from context - @mcp.tool(description="Echo request headers from context", title="Echo Headers") - def echo_headers(ctx: Context[Any, Any, Request]) -> str: - """Returns the request headers as JSON.""" - headers_info = {} - if ctx.request_context.request: - # Now the type system knows request is a Starlette Request object - headers_info = dict(ctx.request_context.request.headers) - return json.dumps(headers_info) - - # Tool that returns full request context - @mcp.tool(description="Echo request context with custom data", title="Echo Context") - def echo_context(custom_request_id: str, ctx: Context[Any, Any, Request]) -> str: - """Returns request context including headers and custom data.""" - context_data = { - "custom_request_id": custom_request_id, - "headers": {}, - "method": None, - "path": None, - } - if ctx.request_context.request: - request = ctx.request_context.request - context_data["headers"] = dict(request.headers) - context_data["method"] = request.method - context_data["path"] = request.url.path - return json.dumps(context_data) - - # Restaurant booking tool with elicitation - @mcp.tool(description="Book a table at a restaurant with elicitation", title="Restaurant Booking") - async def book_restaurant( - date: str, - time: str, - party_size: int, - ctx: Context, - ) -> str: - """Book a table - uses elicitation if requested date is unavailable.""" - - class AlternativeDateSchema(BaseModel): - checkAlternative: bool = Field(description="Would you like to try another date?") - alternativeDate: str = Field( - default="2024-12-26", - description="What date would you prefer? (YYYY-MM-DD)", - ) - - # For demo: assume dates starting with "2024-12-25" are unavailable - if date.startswith("2024-12-25"): - # Use elicitation to ask about alternatives - result = await ctx.elicit( - message=( - f"No tables available for {party_size} people on {date} " - f"at {time}. Would you like to check another date?" - ), - schema=AlternativeDateSchema, - ) - - if result.action == "accept" and result.data: - if result.data.checkAlternative: - alt_date = result.data.alternativeDate - return f"✅ Booked table for {party_size} on {alt_date} at {time}" - else: - return "❌ No booking made" - elif result.action in ("decline", "cancel"): - return "❌ Booking cancelled" - else: - # Handle case where action is "accept" but data is None - return "❌ No booking data received" - else: - # Available - book directly - return f"✅ Booked table for {party_size} on {date} at {time}" - - return mcp diff --git a/examples/snippets/servers/basic_prompt.py b/examples/snippets/servers/basic_prompt.py index c4af39e28..40f606ba6 100644 --- a/examples/snippets/servers/basic_prompt.py +++ b/examples/snippets/servers/basic_prompt.py @@ -1,15 +1,18 @@ from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.prompts import base mcp = FastMCP(name="Prompt Example") -@mcp.prompt(description="Generate a summary") -def summarize(text: str, max_words: int = 100) -> str: - """Create a summarization prompt.""" - return f"Summarize this text in {max_words} words:\n\n{text}" +@mcp.prompt(title="Code Review") +def review_code(code: str) -> str: + return f"Please review this code:\n\n{code}" -@mcp.prompt(description="Explain a concept") -def explain(concept: str, audience: str = "general") -> str: - """Create an explanation prompt.""" - return f"Explain {concept} for a {audience} audience" +@mcp.prompt(title="Debug Assistant") +def debug_error(error: str) -> list[base.Message]: + return [ + base.UserMessage("I'm seeing this error:"), + base.UserMessage(error), + base.AssistantMessage("I'll help debug that. What have you tried so far?"), + ] diff --git a/tests/server/fastmcp/test_integration.py b/tests/server/fastmcp/test_integration.py index e69175554..52c314a2f 100644 --- a/tests/server/fastmcp/test_integration.py +++ b/tests/server/fastmcp/test_integration.py @@ -317,21 +317,33 @@ async def test_basic_prompts(server_transport: str, server_url: str) -> None: assert result.serverInfo.name == "Prompt Example" assert result.capabilities.prompts is not None - # Test summarize prompt + # Test review_code prompt prompts = await session.list_prompts() - summarize_prompt = next((p for p in prompts.prompts if p.name == "summarize"), None) - assert summarize_prompt is not None + review_prompt = next((p for p in prompts.prompts if p.name == "review_code"), None) + assert review_prompt is not None - prompt_result = await session.get_prompt("summarize", {"text": "Long text here", "max_words": "50"}) + prompt_result = await session.get_prompt("review_code", {"code": "def hello():\n print('Hello')"}) assert isinstance(prompt_result, GetPromptResult) - assert len(prompt_result.messages) >= 1 - - # Test explain prompt - explain_result = await session.get_prompt( - "explain", {"concept": "machine learning", "audience": "beginner"} + assert len(prompt_result.messages) == 1 + assert isinstance(prompt_result.messages[0].content, TextContent) + assert "Please review this code:" in prompt_result.messages[0].content.text + assert "def hello():" in prompt_result.messages[0].content.text + + # Test debug_error prompt + debug_result = await session.get_prompt( + "debug_error", {"error": "TypeError: 'NoneType' object is not subscriptable"} ) - assert isinstance(explain_result, GetPromptResult) - assert len(explain_result.messages) >= 1 + assert isinstance(debug_result, GetPromptResult) + assert len(debug_result.messages) == 3 + assert debug_result.messages[0].role == "user" + assert isinstance(debug_result.messages[0].content, TextContent) + assert "I'm seeing this error:" in debug_result.messages[0].content.text + assert debug_result.messages[1].role == "user" + assert isinstance(debug_result.messages[1].content, TextContent) + assert "TypeError" in debug_result.messages[1].content.text + assert debug_result.messages[2].role == "assistant" + assert isinstance(debug_result.messages[2].content, TextContent) + assert "I'll help debug that" in debug_result.messages[2].content.text # Test progress reporting diff --git a/uv.lock b/uv.lock index 5d21ae4c8..0c9641c61 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,6 @@ resolution-mode = "lowest-direct" [manifest] members = [ "mcp", - "mcp-server-everything", "mcp-simple-auth", "mcp-simple-prompt", "mcp-simple-resource", @@ -639,23 +638,6 @@ docs = [ { name = "mkdocstrings-python", specifier = ">=1.12.2" }, ] -[[package]] -name = "mcp-server-everything" -version = "0.1.0" -source = { editable = "examples/servers/everything" } -dependencies = [ - { name = "httpx" }, - { name = "mcp" }, - { name = "pydantic" }, -] - -[package.metadata] -requires-dist = [ - { name = "httpx" }, - { name = "mcp", editable = "." }, - { name = "pydantic", specifier = ">=2.0" }, -] - [[package]] name = "mcp-simple-auth" version = "0.1.0" From ddb24408e5605aba00c3dec8cd740e499e8ed246 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 2 Jul 2025 11:09:54 +0100 Subject: [PATCH 17/25] update docs --- README.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 6ca85075d..1b1b3df95 100644 --- a/README.md +++ b/README.md @@ -389,20 +389,23 @@ Prompts are reusable templates that help LLMs interact with your server effectiv ```python from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.prompts import base mcp = FastMCP(name="Prompt Example") -@mcp.prompt(description="Generate a summary") -def summarize(text: str, max_words: int = 100) -> str: - """Create a summarization prompt.""" - return f"Summarize this text in {max_words} words:\n\n{text}" +@mcp.prompt(title="Code Review") +def review_code(code: str) -> str: + return f"Please review this code:\n\n{code}" -@mcp.prompt(description="Explain a concept") -def explain(concept: str, audience: str = "general") -> str: - """Create an explanation prompt.""" - return f"Explain {concept} for a {audience} audience" +@mcp.prompt(title="Debug Assistant") +def debug_error(error: str) -> list[base.Message]: + return [ + base.UserMessage("I'm seeing this error:"), + base.UserMessage(error), + base.AssistantMessage("I'll help debug that. What have you tried so far?"), + ] ``` _Full example: [examples/snippets/servers/basic_prompt.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_prompt.py)_ From 395f60c71868b2f69dd63d848e4e692886350e76 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 2 Jul 2025 11:26:13 +0100 Subject: [PATCH 18/25] simplify init --- examples/snippets/servers/__init__.py | 99 ++++++++------------------- 1 file changed, 30 insertions(+), 69 deletions(-) diff --git a/examples/snippets/servers/__init__.py b/examples/snippets/servers/__init__.py index 3adc7add8..b1a03652d 100644 --- a/examples/snippets/servers/__init__.py +++ b/examples/snippets/servers/__init__.py @@ -4,9 +4,22 @@ Each server demonstrates a single feature and can be run as a standalone server. """ +import importlib import sys from typing import Literal +# Available snippet modules +SNIPPET_MODULES = [ + "basic_tool", + "basic_resource", + "basic_prompt", + "tool_progress", + "sampling", + "elicitation", + "completion", + "notifications", +] + def run_server(module_name: str, transport: Literal["stdio", "sse", "streamable-http"] | None = None) -> None: """Run a snippet server with the specified transport. @@ -16,41 +29,13 @@ def run_server(module_name: str, transport: Literal["stdio", "sse", "streamable- transport: Transport to use (stdio, sse, streamable-http). If None, uses first command line arg or defaults to stdio. """ - # Import the specific module based on name - if module_name == "basic_tool": - from . import basic_tool - - mcp = basic_tool.mcp - elif module_name == "basic_resource": - from . import basic_resource - - mcp = basic_resource.mcp - elif module_name == "basic_prompt": - from . import basic_prompt - - mcp = basic_prompt.mcp - elif module_name == "tool_progress": - from . import tool_progress - - mcp = tool_progress.mcp - elif module_name == "sampling": - from . import sampling + # Validate module name + if module_name not in SNIPPET_MODULES: + raise ValueError(f"Unknown module: {module_name}. Available modules: {', '.join(SNIPPET_MODULES)}") - mcp = sampling.mcp - elif module_name == "elicitation": - from . import elicitation - - mcp = elicitation.mcp - elif module_name == "completion": - from . import completion - - mcp = completion.mcp - elif module_name == "notifications": - from . import notifications - - mcp = notifications.mcp - else: - raise ValueError(f"Unknown module: {module_name}") + # Import the module dynamically + module = importlib.import_module(f".{module_name}", package=__name__) + mcp = module.mcp # Determine transport if transport is None: @@ -65,42 +50,18 @@ def run_server(module_name: str, transport: Literal["stdio", "sse", "streamable- mcp.run(transport=transport) # type: ignore -# Entry points for each snippet -def run_basic_tool(): - """Run the basic tool example server.""" - run_server("basic_tool") - - -def run_basic_resource(): - """Run the basic resource example server.""" - run_server("basic_resource") - - -def run_basic_prompt(): - """Run the basic prompt example server.""" - run_server("basic_prompt") - - -def run_tool_progress(): - """Run the tool progress example server.""" - run_server("tool_progress") - - -def run_sampling(): - """Run the sampling example server.""" - run_server("sampling") - - -def run_elicitation(): - """Run the elicitation example server.""" - run_server("elicitation") +# Create entry point functions dynamically +def _create_run_function(module_name: str): + """Create a run function for a specific module.""" + def run_function(): + f"""Run the {module_name.replace('_', ' ')} example server.""" + run_server(module_name) -def run_completion(): - """Run the completion example server.""" - run_server("completion") + return run_function -def run_notifications(): - """Run the notifications example server.""" - run_server("notifications") +# Generate entry points for each snippet +for module in SNIPPET_MODULES: + func_name = f"run_{module}" + globals()[func_name] = _create_run_function(module) \ No newline at end of file From 982b1151d1ee19ff576f86a5563f0c6307e46dcb Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 2 Jul 2025 15:44:35 +0100 Subject: [PATCH 19/25] ruff --- examples/snippets/servers/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/snippets/servers/__init__.py b/examples/snippets/servers/__init__.py index b1a03652d..596c33f39 100644 --- a/examples/snippets/servers/__init__.py +++ b/examples/snippets/servers/__init__.py @@ -64,4 +64,4 @@ def run_function(): # Generate entry points for each snippet for module in SNIPPET_MODULES: func_name = f"run_{module}" - globals()[func_name] = _create_run_function(module) \ No newline at end of file + globals()[func_name] = _create_run_function(module) From 78ff6c9fa744c143d742de0c8fffb4dbf3623750 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 4 Jul 2025 11:02:21 +0100 Subject: [PATCH 20/25] clean up after review comments --- README.md | 24 ++++--- examples/fastmcp/desktop.py | 2 +- examples/fastmcp/readme-quickstart.py | 2 +- examples/snippets/pyproject.toml | 9 +-- examples/snippets/servers/__init__.py | 79 +++++++--------------- examples/snippets/servers/basic_tool.py | 6 +- examples/snippets/servers/elicitation.py | 2 +- examples/snippets/servers/notifications.py | 4 +- examples/snippets/servers/sampling.py | 2 +- examples/snippets/servers/tool_progress.py | 2 +- scripts/update_readme_snippets.py | 3 +- tests/server/fastmcp/test_server.py | 4 +- tests/server/fastmcp/test_tool_manager.py | 36 +++++----- 13 files changed, 70 insertions(+), 105 deletions(-) diff --git a/README.md b/README.md index 1b1b3df95..e90ecac42 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ mcp = FastMCP("Demo") # Add an addition tool @mcp.tool() -def add(a: int, b: int) -> int: +def sum(a: int, b: int) -> int: """Add two numbers""" return a + b @@ -246,13 +246,13 @@ from mcp.server.fastmcp import FastMCP mcp = FastMCP(name="Tool Example") -@mcp.tool(description="Add two numbers") -def add(a: int, b: int) -> int: +@mcp.tool() +def sum(a: int, b: int) -> int: """Add two numbers together.""" return a + b -@mcp.tool(description="Get weather for a city") +@mcp.tool() def get_weather(city: str, unit: str = "celsius") -> str: """Get weather for a city.""" # This would normally call a weather API @@ -440,7 +440,7 @@ from mcp.server.fastmcp import Context, FastMCP mcp = FastMCP(name="Progress Example") -@mcp.tool(description="Demonstrates progress reporting") +@mcp.tool() async def long_running_task(task_name: str, ctx: Context, steps: int = 5) -> str: """Execute a task with progress updates.""" await ctx.info(f"Starting: {task_name}") @@ -567,7 +567,7 @@ class BookingPreferences(BaseModel): ) -@mcp.tool(description="Book a restaurant table") +@mcp.tool() async def book_table( date: str, time: str, @@ -612,7 +612,7 @@ from mcp.types import SamplingMessage, TextContent mcp = FastMCP(name="Sampling Example") -@mcp.tool(description="Uses sampling to generate content") +@mcp.tool() async def generate_poem(topic: str, ctx: Context) -> str: """Generate a poem using LLM sampling.""" prompt = f"Write a short poem about {topic}" @@ -645,9 +645,9 @@ from mcp.server.fastmcp import Context, FastMCP mcp = FastMCP(name="Notifications Example") -@mcp.tool(description="Demonstrates logging at different levels") +@mcp.tool() async def process_data(data: str, ctx: Context) -> str: - """Process data with comprehensive logging.""" + """Process data with logging.""" # Different log levels await ctx.debug(f"Debug: Processing '{data}'") await ctx.info("Info: Starting processing") @@ -785,8 +785,9 @@ from mcp.server.fastmcp import FastMCP mcp = FastMCP(name="EchoServer", stateless_http=True) -@mcp.tool(description="A simple echo tool") +@mcp.tool() def echo(message: str) -> str: + """A simple echo tool""" return f"Echo: {message}" ``` @@ -797,8 +798,9 @@ from mcp.server.fastmcp import FastMCP mcp = FastMCP(name="MathServer", stateless_http=True) -@mcp.tool(description="A simple add tool") +@mcp.tool() def add_two(n: int) -> int: + """Tool to add two to the input""" return n + 2 ``` diff --git a/examples/fastmcp/desktop.py b/examples/fastmcp/desktop.py index 8fd71b263..add7f515b 100644 --- a/examples/fastmcp/desktop.py +++ b/examples/fastmcp/desktop.py @@ -20,6 +20,6 @@ def desktop() -> list[str]: @mcp.tool() -def add(a: int, b: int) -> int: +def sum(a: int, b: int) -> int: """Add two numbers""" return a + b diff --git a/examples/fastmcp/readme-quickstart.py b/examples/fastmcp/readme-quickstart.py index d1c522a81..e1abf7c51 100644 --- a/examples/fastmcp/readme-quickstart.py +++ b/examples/fastmcp/readme-quickstart.py @@ -6,7 +6,7 @@ # Add an addition tool @mcp.tool() -def add(a: int, b: int) -> int: +def sum(a: int, b: int) -> int: """Add two numbers""" return a + b diff --git a/examples/snippets/pyproject.toml b/examples/snippets/pyproject.toml index 3445669b7..832f6495b 100644 --- a/examples/snippets/pyproject.toml +++ b/examples/snippets/pyproject.toml @@ -15,11 +15,4 @@ build-backend = "setuptools.build_meta" packages = ["servers"] [project.scripts] -basic-tool = "servers:run_basic_tool" -basic-resource = "servers:run_basic_resource" -basic-prompt = "servers:run_basic_prompt" -tool-progress = "servers:run_tool_progress" -sampling = "servers:run_sampling" -elicitation = "servers:run_elicitation" -completion = "servers:run_completion" -notifications = "servers:run_notifications" \ No newline at end of file +server = "servers:run_server" \ No newline at end of file diff --git a/examples/snippets/servers/__init__.py b/examples/snippets/servers/__init__.py index 596c33f39..e3b778420 100644 --- a/examples/snippets/servers/__init__.py +++ b/examples/snippets/servers/__init__.py @@ -2,66 +2,35 @@ This package contains simple examples of MCP server features. Each server demonstrates a single feature and can be run as a standalone server. + +To run a server, use the command: + uv run server basic_tool sse """ import importlib import sys -from typing import Literal - -# Available snippet modules -SNIPPET_MODULES = [ - "basic_tool", - "basic_resource", - "basic_prompt", - "tool_progress", - "sampling", - "elicitation", - "completion", - "notifications", -] +from typing import Literal, cast -def run_server(module_name: str, transport: Literal["stdio", "sse", "streamable-http"] | None = None) -> None: - """Run a snippet server with the specified transport. +def run_server(): + """Run a server by name with optional transport. - Args: - module_name: Name of the snippet module to run - transport: Transport to use (stdio, sse, streamable-http). - If None, uses first command line arg or defaults to stdio. + Usage: server [transport] + Example: server basic_tool sse """ - # Validate module name - if module_name not in SNIPPET_MODULES: - raise ValueError(f"Unknown module: {module_name}. Available modules: {', '.join(SNIPPET_MODULES)}") - - # Import the module dynamically - module = importlib.import_module(f".{module_name}", package=__name__) - mcp = module.mcp - - # Determine transport - if transport is None: - transport_arg = sys.argv[1] if len(sys.argv) > 1 else "stdio" - # Validate and cast transport - if transport_arg not in ["stdio", "sse", "streamable-http"]: - raise ValueError(f"Invalid transport: {transport_arg}. Must be one of: stdio, sse, streamable-http") - transport = transport_arg # type: ignore - - # Run the server - print(f"Starting {module_name} server with {transport} transport...") - mcp.run(transport=transport) # type: ignore - - -# Create entry point functions dynamically -def _create_run_function(module_name: str): - """Create a run function for a specific module.""" - - def run_function(): - f"""Run the {module_name.replace('_', ' ')} example server.""" - run_server(module_name) - - return run_function - - -# Generate entry points for each snippet -for module in SNIPPET_MODULES: - func_name = f"run_{module}" - globals()[func_name] = _create_run_function(module) + if len(sys.argv) < 2: + print("Usage: server [transport]") + print("Available servers: basic_tool, basic_resource, basic_prompt, tool_progress,") + print(" sampling, elicitation, completion, notifications") + print("Available transports: stdio (default), sse, streamable-http") + sys.exit(1) + + server_name = sys.argv[1] + transport = sys.argv[2] if len(sys.argv) > 2 else "stdio" + + try: + module = importlib.import_module(f".{server_name}", package=__name__) + module.mcp.run(cast(Literal["stdio", "sse", "streamable-http"], transport)) + except ImportError: + print(f"Error: Server '{server_name}' not found") + sys.exit(1) diff --git a/examples/snippets/servers/basic_tool.py b/examples/snippets/servers/basic_tool.py index 22f020c39..550e24080 100644 --- a/examples/snippets/servers/basic_tool.py +++ b/examples/snippets/servers/basic_tool.py @@ -3,13 +3,13 @@ mcp = FastMCP(name="Tool Example") -@mcp.tool(description="Add two numbers") -def add(a: int, b: int) -> int: +@mcp.tool() +def sum(a: int, b: int) -> int: """Add two numbers together.""" return a + b -@mcp.tool(description="Get weather for a city") +@mcp.tool() def get_weather(city: str, unit: str = "celsius") -> str: """Get weather for a city.""" # This would normally call a weather API diff --git a/examples/snippets/servers/elicitation.py b/examples/snippets/servers/elicitation.py index add3c9daf..a9aedc3c4 100644 --- a/examples/snippets/servers/elicitation.py +++ b/examples/snippets/servers/elicitation.py @@ -15,7 +15,7 @@ class BookingPreferences(BaseModel): ) -@mcp.tool(description="Book a restaurant table") +@mcp.tool() async def book_table( date: str, time: str, diff --git a/examples/snippets/servers/notifications.py b/examples/snippets/servers/notifications.py index 9467a071a..96f0bc141 100644 --- a/examples/snippets/servers/notifications.py +++ b/examples/snippets/servers/notifications.py @@ -3,9 +3,9 @@ mcp = FastMCP(name="Notifications Example") -@mcp.tool(description="Demonstrates logging at different levels") +@mcp.tool() async def process_data(data: str, ctx: Context) -> str: - """Process data with comprehensive logging.""" + """Process data with logging.""" # Different log levels await ctx.debug(f"Debug: Processing '{data}'") await ctx.info("Info: Starting processing") diff --git a/examples/snippets/servers/sampling.py b/examples/snippets/servers/sampling.py index ad6e64249..230b15fcf 100644 --- a/examples/snippets/servers/sampling.py +++ b/examples/snippets/servers/sampling.py @@ -4,7 +4,7 @@ mcp = FastMCP(name="Sampling Example") -@mcp.tool(description="Uses sampling to generate content") +@mcp.tool() async def generate_poem(topic: str, ctx: Context) -> str: """Generate a poem using LLM sampling.""" prompt = f"Write a short poem about {topic}" diff --git a/examples/snippets/servers/tool_progress.py b/examples/snippets/servers/tool_progress.py index 171556876..d62e62dd1 100644 --- a/examples/snippets/servers/tool_progress.py +++ b/examples/snippets/servers/tool_progress.py @@ -3,7 +3,7 @@ mcp = FastMCP(name="Progress Example") -@mcp.tool(description="Demonstrates progress reporting") +@mcp.tool() async def long_running_task(task_name: str, ctx: Context, steps: int = 5) -> str: """Execute a task with progress updates.""" await ctx.info(f"Starting: {task_name}") diff --git a/scripts/update_readme_snippets.py b/scripts/update_readme_snippets.py index 9c8b93f52..35099cb94 100755 --- a/scripts/update_readme_snippets.py +++ b/scripts/update_readme_snippets.py @@ -94,9 +94,10 @@ def process_snippet_block(match: re.Match, check_mode: bool = False) -> str: line_ref = "" # Build the replacement block + indented_code = code.replace('\n', f'\n{indent}') replacement = f"""{indent} {indent}```python -{code} +{indent}{indented_code} {indent}``` {indent}_Full example: [{file_path}]({github_url})_ {indent}""" diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py index c30930f7b..37351bc6f 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -146,7 +146,7 @@ async def test_add_tool_decorator(self): mcp = FastMCP() @mcp.tool() - def add(x: int, y: int) -> int: + def sum(x: int, y: int) -> int: return x + y assert len(mcp._tool_manager.list_tools()) == 1 @@ -158,7 +158,7 @@ async def test_add_tool_decorator_incorrect_usage(self): with pytest.raises(TypeError, match="The @tool decorator was used incorrectly"): @mcp.tool # Missing parentheses #type: ignore - def add(x: int, y: int) -> int: + def sum(x: int, y: int) -> int: return x + y @pytest.mark.anyio diff --git a/tests/server/fastmcp/test_tool_manager.py b/tests/server/fastmcp/test_tool_manager.py index 4b2052da5..27e16cc8e 100644 --- a/tests/server/fastmcp/test_tool_manager.py +++ b/tests/server/fastmcp/test_tool_manager.py @@ -19,23 +19,23 @@ class TestAddTools: def test_basic_function(self): """Test registering and running a basic function.""" - def add(a: int, b: int) -> int: + def sum(a: int, b: int) -> int: """Add two numbers.""" return a + b manager = ToolManager() - manager.add_tool(add) + manager.add_tool(sum) - tool = manager.get_tool("add") + tool = manager.get_tool("sum") assert tool is not None - assert tool.name == "add" + assert tool.name == "sum" assert tool.description == "Add two numbers." assert tool.is_async is False assert tool.parameters["properties"]["a"]["type"] == "integer" assert tool.parameters["properties"]["b"]["type"] == "integer" def test_init_with_tools(self, caplog): - def add(a: int, b: int) -> int: + def sum(a: int, b: int) -> int: return a + b class AddArguments(ArgModelBase): @@ -45,10 +45,10 @@ class AddArguments(ArgModelBase): fn_metadata = FuncMetadata(arg_model=AddArguments) original_tool = Tool( - name="add", + name="sum", title="Add Tool", description="Add two numbers.", - fn=add, + fn=sum, fn_metadata=fn_metadata, is_async=False, parameters=AddArguments.model_json_schema(), @@ -56,13 +56,13 @@ class AddArguments(ArgModelBase): annotations=None, ) manager = ToolManager(tools=[original_tool]) - saved_tool = manager.get_tool("add") + saved_tool = manager.get_tool("sum") assert saved_tool == original_tool # warn on duplicate tools with caplog.at_level(logging.WARNING): manager = ToolManager(True, tools=[original_tool, original_tool]) - assert "Tool already exists: add" in caplog.text + assert "Tool already exists: sum" in caplog.text @pytest.mark.anyio async def test_async_function(self): @@ -182,13 +182,13 @@ def f(x: int) -> int: class TestCallTools: @pytest.mark.anyio async def test_call_tool(self): - def add(a: int, b: int) -> int: + def sum(a: int, b: int) -> int: """Add two numbers.""" return a + b manager = ToolManager() - manager.add_tool(add) - result = await manager.call_tool("add", {"a": 1, "b": 2}) + manager.add_tool(sum) + result = await manager.call_tool("sum", {"a": 1, "b": 2}) assert result == 3 @pytest.mark.anyio @@ -232,25 +232,25 @@ async def __call__(self, x: int) -> int: @pytest.mark.anyio async def test_call_tool_with_default_args(self): - def add(a: int, b: int = 1) -> int: + def sum(a: int, b: int = 1) -> int: """Add two numbers.""" return a + b manager = ToolManager() - manager.add_tool(add) - result = await manager.call_tool("add", {"a": 1}) + manager.add_tool(sum) + result = await manager.call_tool("sum", {"a": 1}) assert result == 2 @pytest.mark.anyio async def test_call_tool_with_missing_args(self): - def add(a: int, b: int) -> int: + def sum(a: int, b: int) -> int: """Add two numbers.""" return a + b manager = ToolManager() - manager.add_tool(add) + manager.add_tool(sum) with pytest.raises(ToolError): - await manager.call_tool("add", {"a": 1}) + await manager.call_tool("sum", {"a": 1}) @pytest.mark.anyio async def test_call_unknown_tool(self): From 81b423ec928155f91f5804a9f6353e360f8bb90f Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 4 Jul 2025 11:31:53 +0100 Subject: [PATCH 21/25] remove line extractoin --- scripts/update_readme_snippets.py | 69 +++++++------------------------ 1 file changed, 15 insertions(+), 54 deletions(-) diff --git a/scripts/update_readme_snippets.py b/scripts/update_readme_snippets.py index 35099cb94..380c912f2 100755 --- a/scripts/update_readme_snippets.py +++ b/scripts/update_readme_snippets.py @@ -16,45 +16,17 @@ from pathlib import Path -def extract_lines(file_path: Path, start_line: int, end_line: int) -> str: - """Extract lines from a file. - - Args: - file_path: Path to the file - start_line: Starting line number (1-based) - end_line: Ending line number (1-based, inclusive) - - Returns: - The extracted lines - """ - content = file_path.read_text() - lines = content.splitlines() - # Convert to 0-based indexing - start_idx = max(0, start_line - 1) - end_idx = min(len(lines), end_line) - return "\n".join(lines[start_idx:end_idx]) - - -def get_github_url(file_path: str, start_line: int, end_line: int) -> str: - """Generate a GitHub URL for the file with line highlighting. +def get_github_url(file_path: str) -> str: + """Generate a GitHub URL for the file. Args: file_path: Path to the file relative to repo root - start_line: Starting line number - end_line: Ending line number Returns: - GitHub URL with line highlighting + GitHub URL """ base_url = "https://github.com/modelcontextprotocol/python-sdk/blob/main" - url = f"{base_url}/{file_path}" - - if start_line == end_line: - url += f"#L{start_line}" - else: - url += f"#L{start_line}-L{end_line}" - - return url + return f"{base_url}/{file_path}" def process_snippet_block(match: re.Match, check_mode: bool = False) -> str: @@ -70,32 +42,20 @@ def process_snippet_block(match: re.Match, check_mode: bool = False) -> str: full_match = match.group(0) indent = match.group(1) file_path = match.group(2) - start_line = match.group(3) # May be None - end_line = match.group(4) # May be None try: - # Read and extract the code + # Read the entire file file = Path(file_path) if not file.exists(): print(f"Warning: File not found: {file_path}") return full_match - if start_line and end_line: - # Line range specified - start_line = int(start_line) - end_line = int(end_line) - code = extract_lines(file, start_line, end_line) - github_url = get_github_url(file_path, start_line, end_line) - line_ref = f"#L{start_line}-L{end_line}" - else: - # No line range - use whole file - code = file.read_text().rstrip() - github_url = f"https://github.com/modelcontextprotocol/python-sdk/blob/main/{file_path}" - line_ref = "" + code = file.read_text().rstrip() + github_url = get_github_url(file_path) # Build the replacement block indented_code = code.replace('\n', f'\n{indent}') - replacement = f"""{indent} + replacement = f"""{indent} {indent}```python {indent}{indented_code} {indent}``` @@ -105,7 +65,7 @@ def process_snippet_block(match: re.Match, check_mode: bool = False) -> str: # In check mode, only check if code has changed if check_mode: # Extract existing code from the match - existing_content = match.group(5) + existing_content = match.group(3) if existing_content is not None: existing_lines = existing_content.strip().split("\n") # Find code between ```python and ``` @@ -119,13 +79,15 @@ def process_snippet_block(match: re.Match, check_mode: bool = False) -> str: elif in_code: code_lines.append(line) existing_code = "\n".join(code_lines).strip() - if existing_code == code.strip(): + # Need to remove the indent from existing code for comparison + dedented_existing = "\n".join(line.lstrip() for line in existing_code.split("\n")) + if dedented_existing == code.strip(): return full_match return replacement except Exception as e: - print(f"Error processing {file_path}#L{start_line}-L{end_line}: {e}") + print(f"Error processing {file_path}: {e}") return full_match @@ -146,12 +108,11 @@ def update_readme_snippets(readme_path: Path = Path("README.md"), check_mode: bo content = readme_path.read_text() original_content = content - # Pattern to match snippet-source blocks with optional line ranges + # Pattern to match snippet-source blocks # Matches: - # or: # ... any content ... # - pattern = r"^(\s*)\n" r"(.*?)" r"^\1" + pattern = r"^(\s*)\n" r"(.*?)" r"^\1" # Process all snippet-source blocks updated_content = re.sub( From d42be5a2441fb9ea5daefc8acbdbd9f2b57496ee Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 4 Jul 2025 11:36:08 +0100 Subject: [PATCH 22/25] indent --- scripts/update_readme_snippets.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/update_readme_snippets.py b/scripts/update_readme_snippets.py index 380c912f2..3c494d976 100755 --- a/scripts/update_readme_snippets.py +++ b/scripts/update_readme_snippets.py @@ -79,9 +79,9 @@ def process_snippet_block(match: re.Match, check_mode: bool = False) -> str: elif in_code: code_lines.append(line) existing_code = "\n".join(code_lines).strip() - # Need to remove the indent from existing code for comparison - dedented_existing = "\n".join(line.lstrip() for line in existing_code.split("\n")) - if dedented_existing == code.strip(): + # Compare with the indented version we would generate + expected_code = code.replace('\n', f'\n{indent}').strip() + if existing_code == expected_code: return full_match return replacement From b1a0913aaea7f4ef845da6f7ae9dc1589717f178 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 4 Jul 2025 11:49:56 +0100 Subject: [PATCH 23/25] ruff --- examples/snippets/servers/elicitation.py | 2 +- scripts/update_readme_snippets.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/snippets/servers/elicitation.py b/examples/snippets/servers/elicitation.py index a9aedc3c4..6d150cd6c 100644 --- a/examples/snippets/servers/elicitation.py +++ b/examples/snippets/servers/elicitation.py @@ -27,7 +27,7 @@ async def book_table( if date == "2024-12-25": # Date unavailable - ask user for alternative result = await ctx.elicit( - message=(f"No tables available for {party_size} on {date}. " "Would you like to try another date?"), + message=(f"No tables available for {party_size} on {date}. Would you like to try another date?"), schema=BookingPreferences, ) diff --git a/scripts/update_readme_snippets.py b/scripts/update_readme_snippets.py index 3c494d976..601d176e8 100755 --- a/scripts/update_readme_snippets.py +++ b/scripts/update_readme_snippets.py @@ -54,7 +54,7 @@ def process_snippet_block(match: re.Match, check_mode: bool = False) -> str: github_url = get_github_url(file_path) # Build the replacement block - indented_code = code.replace('\n', f'\n{indent}') + indented_code = code.replace("\n", f"\n{indent}") replacement = f"""{indent} {indent}```python {indent}{indented_code} @@ -80,7 +80,7 @@ def process_snippet_block(match: re.Match, check_mode: bool = False) -> str: code_lines.append(line) existing_code = "\n".join(code_lines).strip() # Compare with the indented version we would generate - expected_code = code.replace('\n', f'\n{indent}').strip() + expected_code = code.replace("\n", f"\n{indent}").strip() if existing_code == expected_code: return full_match From 926c5f85a8f8a4bcbe32288d2d4e84ad946adbcb Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 4 Jul 2025 11:51:34 +0100 Subject: [PATCH 24/25] readme after ruff --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e90ecac42..c59bad11c 100644 --- a/README.md +++ b/README.md @@ -579,7 +579,7 @@ async def book_table( if date == "2024-12-25": # Date unavailable - ask user for alternative result = await ctx.elicit( - message=(f"No tables available for {party_size} on {date}. " "Would you like to try another date?"), + message=(f"No tables available for {party_size} on {date}. Would you like to try another date?"), schema=BookingPreferences, ) From 478ce05f20006b17e778fe84e38c21c2acd09138 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 4 Jul 2025 11:54:14 +0100 Subject: [PATCH 25/25] tests --- tests/server/fastmcp/test_integration.py | 4 ++-- tests/test_examples.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/server/fastmcp/test_integration.py b/tests/server/fastmcp/test_integration.py index 52c314a2f..a1620ca17 100644 --- a/tests/server/fastmcp/test_integration.py +++ b/tests/server/fastmcp/test_integration.py @@ -239,8 +239,8 @@ async def test_basic_tools(server_transport: str, server_url: str) -> None: assert result.serverInfo.name == "Tool Example" assert result.capabilities.tools is not None - # Test add tool - tool_result = await session.call_tool("add", {"a": 5, "b": 3}) + # Test sum tool + tool_result = await session.call_tool("sum", {"a": 5, "b": 3}) assert len(tool_result.content) == 1 assert isinstance(tool_result.content[0], TextContent) assert tool_result.content[0].text == "8" diff --git a/tests/test_examples.py b/tests/test_examples.py index 6a0fc55c4..decffd810 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -56,8 +56,8 @@ async def test_desktop(monkeypatch): monkeypatch.setattr(Path, "home", lambda: Path("/fake/home")) async with client_session(mcp._mcp_server) as client: - # Test the add function - result = await client.call_tool("add", {"a": 1, "b": 2}) + # Test the sum function + result = await client.call_tool("sum", {"a": 1, "b": 2}) assert len(result.content) == 1 content = result.content[0] assert isinstance(content, TextContent)