From aa96d8f0b117c9aabf208c3a27f796da78ec2e28 Mon Sep 17 00:00:00 2001 From: Jonathan Hefner Date: Thu, 19 Feb 2026 16:07:51 -0600 Subject: [PATCH 1/6] Replace `update_readme_snippets.py` with unified `sync_snippets.py` The new script is a superset of the old one. It continues to handle the existing `` markers in `README.v2.md`, and adds support for syncing code snippets into Python docstrings and markdown docs under `docs/`. New capabilities beyond the old script: - Region extraction from example files using `# region` / `# endregion` markers, so a single example file can provide multiple snippets - All source paths resolve relative to the repository root - Scans `src/**/*.py` and `docs/**/*.md` in addition to the README - Caches file contents and extracted regions for efficiency The marker format uses HTML comments (`` / ``), which are invisible when rendered by `mkdocstrings` and do not interfere with `pymdownx.superfences` code fence parsing. --- .github/workflows/shared.yml | 4 +- .pre-commit-config.yaml | 6 +- README.v2.md | 65 +++---- scripts/sync_snippets.py | 298 ++++++++++++++++++++++++++++++ scripts/update_readme_snippets.py | 159 ---------------- 5 files changed, 336 insertions(+), 196 deletions(-) create mode 100644 scripts/sync_snippets.py delete mode 100755 scripts/update_readme_snippets.py diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index 72e328b54..26982c586 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -91,5 +91,5 @@ jobs: - 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 --readme README.v2.md + - name: Check snippets are up to date + run: uv run --frozen python scripts/sync_snippets.py --check diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 42c12fded..40aa36756 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -62,8 +62,8 @@ repos: language: fail files: ^README\.md$ - id: readme-snippets - name: Check README snippets are up to date - entry: uv run --frozen python scripts/update_readme_snippets.py --check + name: Check snippets are up to date + entry: uv run --frozen python scripts/sync_snippets.py --check language: system - files: ^(README\.v2\.md|examples/.*\.py|scripts/update_readme_snippets\.py)$ + files: ^(README\.v2\.md|examples/.*\.py|src/mcp/.*\.py|docs/.*\.md|scripts/sync_snippets\.py)$ pass_filenames: false diff --git a/README.v2.md b/README.v2.md index bd6927bf9..ffbb54170 100644 --- a/README.v2.md +++ b/README.v2.md @@ -181,9 +181,9 @@ def greet_user(name: str, style: str = "friendly") -> str: if __name__ == "__main__": mcp.run(transport="streamable-http", json_response=True) ``` + _Full example: [examples/snippets/servers/mcpserver_quickstart.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/mcpserver_quickstart.py)_ - You can install this server in [Claude Code](https://docs.claude.com/en/docs/claude-code/mcp) and interact with it right away. First, run the server: @@ -279,9 +279,9 @@ def query_db(ctx: Context[AppContext]) -> str: db = ctx.request_context.lifespan_context.db return db.query() ``` + _Full example: [examples/snippets/servers/lifespan_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lifespan_example.py)_ - ### Resources @@ -310,9 +310,9 @@ def get_settings() -> str: "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 @@ -337,9 +337,9 @@ def get_weather(city: str, unit: str = "celsius") -> str: # This would normally call a weather API 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)_ - Tools can optionally receive a Context object by including a parameter with the `Context` type annotation. This context is automatically injected by the MCPServer framework and provides access to MCP capabilities: @@ -367,9 +367,9 @@ async def long_running_task(task_name: str, ctx: Context[ServerSession, None], s return f"Task '{task_name}' completed" ``` + _Full example: [examples/snippets/servers/tool_progress.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/tool_progress.py)_ - #### Structured Output @@ -452,9 +452,9 @@ def empty_result_tool() -> CallToolResult: """For empty results, return CallToolResult with empty content.""" return CallToolResult(content=[]) ``` + _Full example: [examples/snippets/servers/direct_call_tool_result.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/direct_call_tool_result.py)_ - **Important:** `CallToolResult` must always be returned (no `Optional` or `Union`). For empty results, use `CallToolResult(content=[])`. For optional simple types, use `str | None` without `CallToolResult`. @@ -558,9 +558,9 @@ def get_temperature(city: str) -> float: return 22.5 # Returns: {"result": 22.5} ``` + _Full example: [examples/snippets/servers/structured_output.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/structured_output.py)_ - ### Prompts @@ -587,9 +587,9 @@ def debug_error(error: str) -> list[base.Message]: 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)_ - ### Icons @@ -648,9 +648,9 @@ def create_thumbnail(image_path: str) -> Image: img.thumbnail((100, 100)) return Image(data=img.tobytes(), format="png") ``` + _Full example: [examples/snippets/servers/images.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/images.py)_ - ### Context @@ -715,9 +715,9 @@ async def long_running_task(task_name: str, ctx: Context[ServerSession, None], s return f"Task '{task_name}' completed" ``` + _Full example: [examples/snippets/servers/tool_progress.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/tool_progress.py)_ - ### Completions @@ -805,9 +805,10 @@ def main(): if __name__ == "__main__": main() ``` + _Full example: [examples/snippets/clients/completion_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/completion_client.py)_ - + ### Elicitation Request additional information from users. This example shows an Elicitation during a Tool Call: @@ -914,9 +915,9 @@ async def connect_service(service_name: str, ctx: Context[ServerSession, None]) ] ) ``` + _Full example: [examples/snippets/servers/elicitation.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/elicitation.py)_ - Elicitation schemas support default values for all field types. Default values are automatically included in the JSON schema sent to clients, allowing them to pre-populate forms. @@ -959,9 +960,9 @@ async def generate_poem(topic: str, ctx: Context[ServerSession, None]) -> str: 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 @@ -989,9 +990,9 @@ async def process_data(data: str, ctx: Context[ServerSession, None]) -> str: 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 @@ -1049,9 +1050,9 @@ async def get_weather(city: str = "London") -> dict[str, str]: if __name__ == "__main__": mcp.run(transport="streamable-http", json_response=True) ``` + _Full example: [examples/snippets/servers/oauth_server.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/oauth_server.py)_ - For a complete example with separate Authorization Server and Resource Server implementations, see [`examples/servers/simple-auth/`](examples/servers/simple-auth/). @@ -1223,9 +1224,9 @@ def main(): if __name__ == "__main__": main() ``` + _Full example: [examples/snippets/servers/direct_execution.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/direct_execution.py)_ - Run it with: @@ -1272,9 +1273,9 @@ if __name__ == "__main__": # Stateful server with session persistence # mcp.run(transport="streamable-http") ``` + _Full example: [examples/snippets/servers/streamable_config.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_config.py)_ - You can mount multiple MCPServer servers in a Starlette application: @@ -1334,9 +1335,9 @@ app = Starlette( # echo_mcp.streamable_http_app(streamable_http_path="/", stateless_http=True, json_response=True) # math_mcp.streamable_http_app(streamable_http_path="/", stateless_http=True, json_response=True) ``` + _Full example: [examples/snippets/servers/streamable_starlette_mount.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_starlette_mount.py)_ - For low level server with Streamable HTTP implementations, see: @@ -1429,9 +1430,9 @@ app = Starlette( lifespan=lifespan, ) ``` + _Full example: [examples/snippets/servers/streamable_http_basic_mounting.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_basic_mounting.py)_ - ##### Host-based routing @@ -1476,9 +1477,9 @@ app = Starlette( lifespan=lifespan, ) ``` + _Full example: [examples/snippets/servers/streamable_http_host_mounting.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_host_mounting.py)_ - ##### Multiple servers with path configuration @@ -1533,9 +1534,9 @@ app = Starlette( lifespan=lifespan, ) ``` + _Full example: [examples/snippets/servers/streamable_http_multiple_servers.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_multiple_servers.py)_ - ##### Path configuration at initialization @@ -1573,9 +1574,9 @@ app = Starlette( ] ) ``` + _Full example: [examples/snippets/servers/streamable_http_path_config.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_path_config.py)_ - #### SSE servers @@ -1738,9 +1739,9 @@ if __name__ == "__main__": asyncio.run(run()) ``` + _Full example: [examples/snippets/servers/lowlevel/lifespan.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/lifespan.py)_ - The lifespan API provides: @@ -1814,9 +1815,9 @@ async def run(): if __name__ == "__main__": asyncio.run(run()) ``` + _Full example: [examples/snippets/servers/lowlevel/basic.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/basic.py)_ - Caution: The `uv run mcp run` and `uv run mcp dev` tool doesn't support low-level server. @@ -1907,9 +1908,9 @@ async def run(): if __name__ == "__main__": asyncio.run(run()) ``` + _Full example: [examples/snippets/servers/lowlevel/structured_output.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/structured_output.py)_ - With the low-level server, handlers always return `CallToolResult` directly. You construct both the human-readable `content` and the machine-readable `structured_content` yourself, giving you full control over the response. @@ -1982,9 +1983,9 @@ async def run(): if __name__ == "__main__": asyncio.run(run()) ``` + _Full example: [examples/snippets/servers/lowlevel/direct_call_tool_result.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/direct_call_tool_result.py)_ - ### Pagination (Advanced) @@ -2030,9 +2031,9 @@ async def handle_list_resources( server = Server("paginated-server", on_list_resources=handle_list_resources) ``` + _Full example: [examples/snippets/servers/pagination_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/pagination_example.py)_ - #### Client-side Consumption @@ -2078,9 +2079,9 @@ async def list_all_resources() -> None: if __name__ == "__main__": asyncio.run(list_all_resources()) ``` + _Full example: [examples/snippets/clients/pagination_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/pagination_client.py)_ - #### Key Points @@ -2178,9 +2179,9 @@ def main(): if __name__ == "__main__": main() ``` + _Full example: [examples/snippets/clients/stdio_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/stdio_client.py)_ - Clients can also connect using [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http): @@ -2211,9 +2212,9 @@ async def main(): if __name__ == "__main__": asyncio.run(main()) ``` + _Full example: [examples/snippets/clients/streamable_basic.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/streamable_basic.py)_ - ### Client Display Utilities @@ -2288,9 +2289,9 @@ def main(): if __name__ == "__main__": main() ``` + _Full example: [examples/snippets/clients/display_utilities.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/display_utilities.py)_ - The `get_display_name()` function implements the proper precedence rules for displaying names: @@ -2394,9 +2395,9 @@ def run(): if __name__ == "__main__": run() ``` + _Full example: [examples/snippets/clients/oauth_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/oauth_client.py)_ - For a complete working example, see [`examples/clients/simple-auth-client/`](examples/clients/simple-auth-client/). diff --git a/scripts/sync_snippets.py b/scripts/sync_snippets.py new file mode 100644 index 000000000..84adc3707 --- /dev/null +++ b/scripts/sync_snippets.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +"""Sync code snippets from example files into docstrings and markdown. + +This script finds snippet-source markers in Python source files and markdown +files, and replaces the content between them with code from the referenced +example files. + +Supported target files: +- Python source files under src/ (docstring code examples) +- Markdown files under docs/ +- README*.md files at the repo root + +Marker format (same in both docstrings and markdown): + + + ```python + # content replaced by script + ``` + + +With region extraction: + + + ```python + # content replaced by script + ``` + + +The code fence language is inferred from the source file extension. + +Region markers in example files: + + # region region_name + code here + # endregion region_name + +Path resolution: +- All paths are relative to the repository root + +Usage: + uv run python scripts/sync_snippets.py # Sync all snippets + uv run python scripts/sync_snippets.py --check # Check mode for CI +""" + +from __future__ import annotations + +import argparse +import re +import sys +import textwrap +from dataclasses import dataclass, field +from pathlib import Path + +# Pattern to match snippet-source blocks. +# Captures: indent, source path, content between markers. +SNIPPET_PATTERN = re.compile( + r"^(?P[ \t]*)\n" + r"(?P.*?)" + r"^(?P=indent)", + re.MULTILINE | re.DOTALL, +) + +# Region markers in example files. +REGION_START_PATTERN = re.compile(r"^(?P\s*)# region (?P\S+)\s*$") +REGION_END_PATTERN = re.compile(r"^\s*# endregion (?P\S+)\s*$") + + +def find_repo_root() -> Path: + """Find the repository root by looking for pyproject.toml.""" + current = Path(__file__).resolve().parent + while current != current.parent: + if (current / "pyproject.toml").exists(): + return current + current = current.parent + raise RuntimeError("Could not find repository root (no pyproject.toml found)") + + +def resolve_source_path(source_path: str, repo_root: Path) -> Path: + """Resolve a source path relative to the repository root.""" + return (repo_root / source_path).resolve() + + +def list_regions(content: str) -> list[str]: + """List all region names defined in file content.""" + regions: list[str] = [] + for line in content.split("\n"): + m = REGION_START_PATTERN.match(line) + if m: + regions.append(m.group("name")) + return regions + + +def extract_region(content: str, region_name: str, file_path: str) -> str: + """Extract a named region from file content. + + Regions are delimited by: + # region region_name + ... code ... + # endregion region_name + + The extracted content is dedented using textwrap.dedent. + """ + lines = content.split("\n") + + start_idx = None + for i, line in enumerate(lines): + m = REGION_START_PATTERN.match(line) + if m and m.group("name") == region_name: + start_idx = i + break + + if start_idx is None: + available = list_regions(content) + available_str = ", ".join(available) if available else "(none)" + raise ValueError(f"Region '{region_name}' not found in {file_path}. Available regions: {available_str}") + + end_idx = None + for i in range(start_idx + 1, len(lines)): + m = REGION_END_PATTERN.match(lines[i]) + if m and m.group("name") == region_name: + end_idx = i + break + + if end_idx is None: + raise ValueError(f"No matching '# endregion {region_name}' found in {file_path}") + + region_lines = lines[start_idx + 1 : end_idx] + region_content = "\n".join(region_lines) + + return textwrap.dedent(region_content).strip() + + +@dataclass +class ProcessingResult: + """Result of processing a single file.""" + + file_path: Path + modified: bool = False + snippets_processed: int = 0 + errors: list[str] = field(default_factory=lambda: []) + + +class SnippetSyncer: + """Syncs code snippets from example files into target files.""" + + def __init__(self, repo_root: Path) -> None: + self.repo_root = repo_root + self._file_cache: dict[str, str] = {} + self._region_cache: dict[str, str] = {} + + def get_file_content(self, resolved_path: Path) -> str: + """Get file content, using cache.""" + key = str(resolved_path) + if key not in self._file_cache: + if not resolved_path.exists(): + raise FileNotFoundError(f"Example file not found: {resolved_path}") + self._file_cache[key] = resolved_path.read_text() + return self._file_cache[key] + + def get_source_content(self, source_ref: str) -> str: + """Get the content for a source reference (path or path#region).""" + if "#" in source_ref: + file_path_str, region_name = source_ref.rsplit("#", 1) + else: + file_path_str = source_ref + region_name = None + + resolved = resolve_source_path(file_path_str, self.repo_root) + file_content = self.get_file_content(resolved) + + if region_name is None: + return file_content.strip() + + cache_key = f"{resolved}#{region_name}" + if cache_key not in self._region_cache: + self._region_cache[cache_key] = extract_region(file_content, region_name, file_path_str) + return self._region_cache[cache_key] + + def process_file(self, file_path: Path, *, check: bool = False) -> ProcessingResult: + """Process a single file to sync snippets.""" + result = ProcessingResult(file_path=file_path) + + content = file_path.read_text() + original_content = content + + def replace_snippet(match: re.Match[str]) -> str: + indent = match.group("indent") + source_ref = match.group("source") + + try: + code = self.get_source_content(source_ref) + except (FileNotFoundError, ValueError) as e: + result.errors.append(f"{file_path}: {e}") + return match.group(0) + + result.snippets_processed += 1 + + # Infer language from file extension + raw_path = source_ref.split("#")[0] + ext = Path(raw_path).suffix.lstrip(".") + lang = {"py": "python", "yml": "yaml"}.get(ext, ext) + + # Indent the code to match the marker indentation + indented_code = textwrap.indent(code, indent) + + # Build replacement block + lines = [ + f"{indent}", + f"{indent}```{lang}", + indented_code, + f"{indent}```", + f"{indent}", + ] + return "\n".join(lines) + + content = SNIPPET_PATTERN.sub(replace_snippet, content) + + if content != original_content: + result.modified = True + if not check: + file_path.write_text(content) + + return result + + def find_target_files(self) -> list[Path]: + """Find all files that should be scanned for snippet markers.""" + files: list[Path] = [] + + # Python source files + src_dir = self.repo_root / "src" + if src_dir.exists(): + files.extend(src_dir.rglob("*.py")) + + # Markdown docs + docs_dir = self.repo_root / "docs" + if docs_dir.exists(): + files.extend(docs_dir.rglob("*.md")) + + # TODO(v2): Change to README.md when v2 is released. + readme = self.repo_root / "README.v2.md" + if readme.exists(): + files.append(readme) + + return sorted(files) + + +def main() -> None: + """Main entry point.""" + parser = argparse.ArgumentParser(description="Sync code snippets from example files") + parser.add_argument( + "--check", + action="store_true", + help="Check mode - verify snippets are up to date without modifying", + ) + args = parser.parse_args() + + repo_root = find_repo_root() + syncer = SnippetSyncer(repo_root) + + if args.check: + print("Checking code snippets are in sync...\n") + else: + print("Syncing code snippets from example files...\n") + + files = syncer.find_target_files() + results = [syncer.process_file(f, check=args.check) for f in files] + + # Report + modified = [r for r in results if r.modified] + all_errors: list[str] = [] + for r in results: + all_errors.extend(r.errors) + + if modified: + if args.check: + print(f"{len(modified)} file(s) out of sync:") + else: + print(f"Modified {len(modified)} file(s):") + for r in modified: + print(f" {r.file_path} ({r.snippets_processed} snippet(s))") + else: + print("All snippets are up to date") + + if all_errors: + print("\nErrors:") + for error in all_errors: + print(f" {error}") + sys.exit(2) + + if args.check and modified: + print("\nRun 'uv run python scripts/sync_snippets.py' to fix.") + sys.exit(1) + + print("\nSnippet sync complete!") + + +if __name__ == "__main__": + main() diff --git a/scripts/update_readme_snippets.py b/scripts/update_readme_snippets.py deleted file mode 100755 index 8a534e5cb..000000000 --- a/scripts/update_readme_snippets.py +++ /dev/null @@ -1,159 +0,0 @@ -#!/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 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 - - Returns: - GitHub URL - """ - base_url = "https://github.com/modelcontextprotocol/python-sdk/blob/main" - return f"{base_url}/{file_path}" - - -def process_snippet_block(match: re.Match[str], 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) - - try: - # Read the entire file - file = Path(file_path) - if not file.exists(): - print(f"Warning: File not found: {file_path}") - return full_match - - 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} -{indent}```python -{indent}{indented_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(3) - 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() - # 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 - - except Exception as e: - print(f"Error processing {file_path}: {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 - # 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" - ) - # TODO(v2): Drop the `--readme` argument when v2 is released, and set to `README.md`. - parser.add_argument("--readme", default="README.v2.md", help="Path to README file (default: README.v2.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() From 5c6bea59c5286a18fc4049a8cf13c65035f931de Mon Sep 17 00:00:00 2001 From: Jonathan Hefner Date: Fri, 20 Feb 2026 12:20:00 -0600 Subject: [PATCH 2/6] Support path-less `#region` markers in `sync_snippets.py` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for `` markers that omit the companion file path. The path is derived from the target file's location using the mapping `src/X` → `examples/snippets/docstrings/X`. This eliminates line-length violations on marker lines in docstrings, since the full companion path no longer needs to be embedded in every marker. The full-path form continues to work for non-`src/` targets like markdown files. --- scripts/sync_snippets.py | 45 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/scripts/sync_snippets.py b/scripts/sync_snippets.py index 84adc3707..973eed5ad 100644 --- a/scripts/sync_snippets.py +++ b/scripts/sync_snippets.py @@ -26,6 +26,17 @@ ``` +Path-less region markers (for src/ files only): + + + ```python + # content replaced by script + ``` + + + The companion file path is derived from the target file's location: + src/mcp/foo/bar.py → examples/snippets/docstrings/mcp/foo/bar.py + The code fence language is inferred from the source file extension. Region markers in example files: @@ -36,6 +47,7 @@ Path resolution: - All paths are relative to the repository root +- Path-less markers (#region) resolve via: src/X → COMPANION_BASE/X Usage: uv run python scripts/sync_snippets.py # Sync all snippets @@ -64,6 +76,12 @@ REGION_START_PATTERN = re.compile(r"^(?P\s*)# region (?P\S+)\s*$") REGION_END_PATTERN = re.compile(r"^\s*# endregion (?P\S+)\s*$") +# Base directory for companion example files (relative to repo root). +COMPANION_BASE = Path("examples/snippets/docstrings") + +# Source prefix stripped when deriving companion paths. +SOURCE_PREFIX = Path("src") + def find_repo_root() -> Path: """Find the repository root by looking for pyproject.toml.""" @@ -148,6 +166,28 @@ def __init__(self, repo_root: Path) -> None: self._file_cache: dict[str, str] = {} self._region_cache: dict[str, str] = {} + def derive_companion_path(self, target_file: Path) -> str: + """Derive the companion example file path from a source file path. + + Maps src/mcp/X → examples/snippets/docstrings/mcp/X + """ + rel = target_file.relative_to(self.repo_root) + try: + sub = rel.relative_to(SOURCE_PREFIX) + except ValueError: + raise ValueError( + f"Cannot derive companion path for {rel}: " + f"path-less #region markers are only supported in {SOURCE_PREFIX}/ files" + ) from None + return str(COMPANION_BASE / sub) + + def resolve_source_ref(self, source_ref: str, target_file: Path) -> str: + """Resolve a source reference, expanding path-less #region markers.""" + if source_ref.startswith("#"): + companion = self.derive_companion_path(target_file) + return f"{companion}{source_ref}" + return source_ref + def get_file_content(self, resolved_path: Path) -> str: """Get file content, using cache.""" key = str(resolved_path) @@ -188,7 +228,8 @@ def replace_snippet(match: re.Match[str]) -> str: source_ref = match.group("source") try: - code = self.get_source_content(source_ref) + resolved_ref = self.resolve_source_ref(source_ref, file_path) + code = self.get_source_content(resolved_ref) except (FileNotFoundError, ValueError) as e: result.errors.append(f"{file_path}: {e}") return match.group(0) @@ -196,7 +237,7 @@ def replace_snippet(match: re.Match[str]) -> str: result.snippets_processed += 1 # Infer language from file extension - raw_path = source_ref.split("#")[0] + raw_path = resolved_ref.split("#")[0] ext = Path(raw_path).suffix.lstrip(".") lang = {"py": "python", "yml": "yaml"}.get(ext, ext) From 4e5211bbbe8d2947fca0a328a136f8512a59e797 Mon Sep 17 00:00:00 2001 From: Jonathan Hefner Date: Fri, 20 Feb 2026 14:16:16 -0600 Subject: [PATCH 3/6] Extract docstring code examples into type-checked companion files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move inline code examples from docstrings in `src/mcp/` into standalone companion files at `examples/snippets/docstrings/mcp/`, mirroring the source tree structure. The `scripts/sync_snippets.py` script keeps the docstring content in sync with the companion files via `` markers. This ensures all docstring examples are checked by pyright and ruff, catching type errors and style drift that would otherwise go unnoticed in raw docstring text. The pattern follows the TypeScript SDK's approach of one companion file per source file, with each example in a named function whose parameters supply typed context. 19 companion files cover 42 code examples across the public API surface: `MCPServer`, `Client`, `ClientSession`, `Context`, `ResponseRouter`, `ServerTaskContext`, `ExperimentalTaskHandlers`, `ClientCredentialsOAuthProvider`, `PrivateKeyJWTOAuthProvider`, `SignedJWTParameters`, and others. All source markers use path-less form (`#RegionName`) — the companion path is derived automatically from the target file location. Region names follow `ClassName_methodName_variant` without abbreviation. Pyright execution environment for the companion files suppresses only "unused artifact" diagnostics inherent to example code (`reportUnusedFunction`, `reportUnusedVariable`, `reportAbstractUsage`, `reportUnusedClass`, `reportPrivateUsage`). All actual type-checking rules remain enabled. --- .../auth/extensions/client_credentials.py | 95 ++++++++++ .../snippets/docstrings/mcp/client/client.py | 24 +++ .../mcp/client/experimental/task_handlers.py | 36 ++++ .../mcp/client/experimental/tasks.py | 60 ++++++ .../snippets/docstrings/mcp/client/session.py | 13 ++ .../docstrings/mcp/client/session_group.py | 19 ++ .../server/experimental/request_context.py | 31 +++ .../mcp/server/experimental/task_context.py | 24 +++ .../mcp/server/experimental/task_support.py | 33 ++++ .../mcp/server/lowlevel/experimental.py | 32 ++++ .../docstrings/mcp/server/mcpserver/server.py | 178 ++++++++++++++++++ .../snippets/docstrings/mcp/server/sse.py | 42 +++++ .../snippets/docstrings/mcp/server/stdio.py | 28 +++ .../docstrings/mcp/shared/_httpx_utils.py | 40 ++++ .../docstrings/mcp/shared/exceptions.py | 20 ++ .../mcp/shared/experimental/tasks/helpers.py | 17 ++ .../docstrings/mcp/shared/metadata_utils.py | 16 ++ .../docstrings/mcp/shared/response_router.py | 24 +++ .../snippets/docstrings/mcp/shared/session.py | 14 ++ examples/snippets/pyproject.toml | 4 +- pyproject.toml | 2 + .../auth/extensions/client_credentials.py | 12 ++ src/mcp/client/client.py | 2 + src/mcp/client/experimental/task_handlers.py | 4 +- src/mcp/client/experimental/tasks.py | 10 +- src/mcp/client/session.py | 2 + src/mcp/client/session_group.py | 6 +- .../server/experimental/request_context.py | 14 +- src/mcp/server/experimental/task_context.py | 6 +- src/mcp/server/experimental/task_support.py | 4 + src/mcp/server/lowlevel/experimental.py | 4 + src/mcp/server/mcpserver/server.py | 48 ++++- src/mcp/server/sse.py | 24 +-- src/mcp/server/stdio.py | 2 + src/mcp/shared/_httpx_utils.py | 9 + src/mcp/shared/exceptions.py | 18 +- src/mcp/shared/experimental/tasks/helpers.py | 4 +- src/mcp/shared/metadata_utils.py | 2 + src/mcp/shared/response_router.py | 6 +- src/mcp/shared/session.py | 2 + 40 files changed, 893 insertions(+), 38 deletions(-) create mode 100644 examples/snippets/docstrings/mcp/client/auth/extensions/client_credentials.py create mode 100644 examples/snippets/docstrings/mcp/client/client.py create mode 100644 examples/snippets/docstrings/mcp/client/experimental/task_handlers.py create mode 100644 examples/snippets/docstrings/mcp/client/experimental/tasks.py create mode 100644 examples/snippets/docstrings/mcp/client/session.py create mode 100644 examples/snippets/docstrings/mcp/client/session_group.py create mode 100644 examples/snippets/docstrings/mcp/server/experimental/request_context.py create mode 100644 examples/snippets/docstrings/mcp/server/experimental/task_context.py create mode 100644 examples/snippets/docstrings/mcp/server/experimental/task_support.py create mode 100644 examples/snippets/docstrings/mcp/server/lowlevel/experimental.py create mode 100644 examples/snippets/docstrings/mcp/server/mcpserver/server.py create mode 100644 examples/snippets/docstrings/mcp/server/sse.py create mode 100644 examples/snippets/docstrings/mcp/server/stdio.py create mode 100644 examples/snippets/docstrings/mcp/shared/_httpx_utils.py create mode 100644 examples/snippets/docstrings/mcp/shared/exceptions.py create mode 100644 examples/snippets/docstrings/mcp/shared/experimental/tasks/helpers.py create mode 100644 examples/snippets/docstrings/mcp/shared/metadata_utils.py create mode 100644 examples/snippets/docstrings/mcp/shared/response_router.py create mode 100644 examples/snippets/docstrings/mcp/shared/session.py diff --git a/examples/snippets/docstrings/mcp/client/auth/extensions/client_credentials.py b/examples/snippets/docstrings/mcp/client/auth/extensions/client_credentials.py new file mode 100644 index 000000000..b8b145fde --- /dev/null +++ b/examples/snippets/docstrings/mcp/client/auth/extensions/client_credentials.py @@ -0,0 +1,95 @@ +"""Companion examples for src/mcp/client/auth/extensions/client_credentials.py docstrings.""" + +from __future__ import annotations + +from mcp.client.auth import TokenStorage +from mcp.client.auth.extensions.client_credentials import ( + ClientCredentialsOAuthProvider, + PrivateKeyJWTOAuthProvider, + SignedJWTParameters, + static_assertion_provider, +) + + +async def fetch_token_from_identity_provider(*, audience: str) -> str: ... + + +def ClientCredentialsOAuthProvider_init(my_token_storage: TokenStorage) -> None: + # region ClientCredentialsOAuthProvider_init + provider = ClientCredentialsOAuthProvider( + server_url="https://api.example.com", + storage=my_token_storage, + client_id="my-client-id", + client_secret="my-client-secret", + ) + # endregion ClientCredentialsOAuthProvider_init + + +def static_assertion_provider_usage(my_token_storage: TokenStorage, my_prebuilt_jwt: str) -> None: + # region static_assertion_provider_usage + provider = PrivateKeyJWTOAuthProvider( + server_url="https://api.example.com", + storage=my_token_storage, + client_id="my-client-id", + assertion_provider=static_assertion_provider(my_prebuilt_jwt), + ) + # endregion static_assertion_provider_usage + + +def SignedJWTParameters_usage(my_token_storage: TokenStorage, private_key_pem: str) -> None: + # region SignedJWTParameters_usage + jwt_params = SignedJWTParameters( + issuer="my-client-id", + subject="my-client-id", + signing_key=private_key_pem, + ) + provider = PrivateKeyJWTOAuthProvider( + server_url="https://api.example.com", + storage=my_token_storage, + client_id="my-client-id", + assertion_provider=jwt_params.create_assertion_provider(), + ) + # endregion SignedJWTParameters_usage + + +def PrivateKeyJWTOAuthProvider_workloadIdentity(my_token_storage: TokenStorage) -> None: + # region PrivateKeyJWTOAuthProvider_workloadIdentity + async def get_workload_identity_token(audience: str) -> str: + # Fetch JWT from your identity provider + # The JWT's audience must match the provided audience parameter + return await fetch_token_from_identity_provider(audience=audience) + + provider = PrivateKeyJWTOAuthProvider( + server_url="https://api.example.com", + storage=my_token_storage, + client_id="my-client-id", + assertion_provider=get_workload_identity_token, + ) + # endregion PrivateKeyJWTOAuthProvider_workloadIdentity + + +def PrivateKeyJWTOAuthProvider_staticJWT(my_token_storage: TokenStorage, my_prebuilt_jwt: str) -> None: + # region PrivateKeyJWTOAuthProvider_staticJWT + provider = PrivateKeyJWTOAuthProvider( + server_url="https://api.example.com", + storage=my_token_storage, + client_id="my-client-id", + assertion_provider=static_assertion_provider(my_prebuilt_jwt), + ) + # endregion PrivateKeyJWTOAuthProvider_staticJWT + + +def PrivateKeyJWTOAuthProvider_sdkSigned(my_token_storage: TokenStorage, private_key_pem: str) -> None: + # region PrivateKeyJWTOAuthProvider_sdkSigned + jwt_params = SignedJWTParameters( + issuer="my-client-id", + subject="my-client-id", + signing_key=private_key_pem, + ) + provider = PrivateKeyJWTOAuthProvider( + server_url="https://api.example.com", + storage=my_token_storage, + client_id="my-client-id", + assertion_provider=jwt_params.create_assertion_provider(), + ) + # endregion PrivateKeyJWTOAuthProvider_sdkSigned diff --git a/examples/snippets/docstrings/mcp/client/client.py b/examples/snippets/docstrings/mcp/client/client.py new file mode 100644 index 000000000..2552e67ac --- /dev/null +++ b/examples/snippets/docstrings/mcp/client/client.py @@ -0,0 +1,24 @@ +"""Companion examples for src/mcp/client/client.py docstrings.""" + +from __future__ import annotations + +import asyncio + + +def Client_usage() -> None: + # region Client_usage + from mcp.client import Client + from mcp.server.mcpserver import MCPServer + + server = MCPServer("test") + + @server.tool() + def add(a: int, b: int) -> int: + return a + b + + async def main(): + async with Client(server) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + + asyncio.run(main()) + # endregion Client_usage diff --git a/examples/snippets/docstrings/mcp/client/experimental/task_handlers.py b/examples/snippets/docstrings/mcp/client/experimental/task_handlers.py new file mode 100644 index 000000000..9abd0bc89 --- /dev/null +++ b/examples/snippets/docstrings/mcp/client/experimental/task_handlers.py @@ -0,0 +1,36 @@ +"""Companion examples for src/mcp/client/experimental/task_handlers.py docstrings.""" + +from __future__ import annotations + +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream + +from mcp import types +from mcp.client.experimental.task_handlers import ExperimentalTaskHandlers +from mcp.client.session import ClientSession +from mcp.shared._context import RequestContext +from mcp.shared.session import SessionMessage + + +async def my_get_task_handler( + context: RequestContext[ClientSession], + params: types.GetTaskRequestParams, +) -> types.GetTaskResult | types.ErrorData: ... + + +async def my_list_tasks_handler( + context: RequestContext[ClientSession], + params: types.PaginatedRequestParams | None, +) -> types.ListTasksResult | types.ErrorData: ... + + +def ExperimentalTaskHandlers_usage( + read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], + write_stream: MemoryObjectSendStream[SessionMessage], +) -> None: + # region ExperimentalTaskHandlers_usage + handlers = ExperimentalTaskHandlers( + get_task=my_get_task_handler, + list_tasks=my_list_tasks_handler, + ) + session = ClientSession(read_stream, write_stream, experimental_task_handlers=handlers) + # endregion ExperimentalTaskHandlers_usage diff --git a/examples/snippets/docstrings/mcp/client/experimental/tasks.py b/examples/snippets/docstrings/mcp/client/experimental/tasks.py new file mode 100644 index 000000000..423b4754f --- /dev/null +++ b/examples/snippets/docstrings/mcp/client/experimental/tasks.py @@ -0,0 +1,60 @@ +"""Companion examples for src/mcp/client/experimental/tasks.py docstrings.""" + +from __future__ import annotations + +import anyio + +from mcp.client.session import ClientSession +from mcp.types import CallToolResult + + +async def module_overview(session: ClientSession) -> None: + # region module_overview + # Call a tool as a task + result = await session.experimental.call_tool_as_task("tool_name", {"arg": "value"}) + task_id = result.task.task_id + + # Get task status + status = await session.experimental.get_task(task_id) + + # Get task result when complete + if status.status == "completed": + result = await session.experimental.get_task_result(task_id, CallToolResult) + + # List all tasks + tasks = await session.experimental.list_tasks() + + # Cancel a task + await session.experimental.cancel_task(task_id) + # endregion module_overview + + +async def ExperimentalClientFeatures_call_tool_as_task_usage(session: ClientSession) -> None: + # region ExperimentalClientFeatures_call_tool_as_task_usage + # Create task + result = await session.experimental.call_tool_as_task("long_running_tool", {"input": "data"}) + task_id = result.task.task_id + + # Poll for completion + while True: + status = await session.experimental.get_task(task_id) + if status.status == "completed": + break + await anyio.sleep(0.5) + + # Get result + final = await session.experimental.get_task_result(task_id, CallToolResult) + # endregion ExperimentalClientFeatures_call_tool_as_task_usage + + +async def ExperimentalClientFeatures_poll_task_usage(session: ClientSession, task_id: str) -> None: + # region ExperimentalClientFeatures_poll_task_usage + async for status in session.experimental.poll_task(task_id): + print(f"Status: {status.status}") + if status.status == "input_required": + # Handle elicitation request via tasks/result + pass + + # Task is now terminal, get the result + result = await session.experimental.get_task_result(task_id, CallToolResult) + # endregion ExperimentalClientFeatures_poll_task_usage diff --git a/examples/snippets/docstrings/mcp/client/session.py b/examples/snippets/docstrings/mcp/client/session.py new file mode 100644 index 000000000..dee88ee6c --- /dev/null +++ b/examples/snippets/docstrings/mcp/client/session.py @@ -0,0 +1,13 @@ +"""Companion examples for src/mcp/client/session.py docstrings.""" + +from __future__ import annotations + +from mcp.client.session import ClientSession +from mcp.types import CallToolResult + + +async def ClientSession_experimental_usage(session: ClientSession, task_id: str) -> None: + # region ClientSession_experimental_usage + status = await session.experimental.get_task(task_id) + result = await session.experimental.get_task_result(task_id, CallToolResult) + # endregion ClientSession_experimental_usage diff --git a/examples/snippets/docstrings/mcp/client/session_group.py b/examples/snippets/docstrings/mcp/client/session_group.py new file mode 100644 index 000000000..de9ef9636 --- /dev/null +++ b/examples/snippets/docstrings/mcp/client/session_group.py @@ -0,0 +1,19 @@ +"""Companion examples for src/mcp/client/session_group.py docstrings.""" + +from __future__ import annotations + +from typing import Any + +from mcp.client.session_group import ClientSessionGroup + + +async def ClientSessionGroup_usage(server_params: list[Any]) -> None: + # region ClientSessionGroup_usage + def name_fn(name: str, server_info: Any) -> str: + return f"{server_info.name}_{name}" + + async with ClientSessionGroup(component_name_hook=name_fn) as group: + for server_param in server_params: + await group.connect_to_server(server_param) + ... + # endregion ClientSessionGroup_usage diff --git a/examples/snippets/docstrings/mcp/server/experimental/request_context.py b/examples/snippets/docstrings/mcp/server/experimental/request_context.py new file mode 100644 index 000000000..ae9571f74 --- /dev/null +++ b/examples/snippets/docstrings/mcp/server/experimental/request_context.py @@ -0,0 +1,31 @@ +"""Companion examples for src/mcp/server/experimental/request_context.py docstrings.""" + +from __future__ import annotations + +from typing import Any + +from mcp.server.context import ServerRequestContext +from mcp.server.experimental.task_context import ServerTaskContext +from mcp.types import CallToolRequestParams, CallToolResult, CreateTaskResult, TextContent + + +def Experimental_run_task_usage() -> None: + # region Experimental_run_task_usage + async def handle_tool( + ctx: ServerRequestContext[Any, Any], + params: CallToolRequestParams, + ) -> CreateTaskResult: + async def work(task: ServerTaskContext) -> CallToolResult: + result = await task.elicit( + message="Are you sure?", + requested_schema={"type": "object", "properties": {"confirm": {"type": "boolean"}}}, + ) + if result.action == "accept" and result.content: + confirmed = result.content.get("confirm", False) + else: + confirmed = False + return CallToolResult(content=[TextContent(text="Done" if confirmed else "Cancelled")]) + + return await ctx.experimental.run_task(work) + + # endregion Experimental_run_task_usage diff --git a/examples/snippets/docstrings/mcp/server/experimental/task_context.py b/examples/snippets/docstrings/mcp/server/experimental/task_context.py new file mode 100644 index 000000000..cd50c8c96 --- /dev/null +++ b/examples/snippets/docstrings/mcp/server/experimental/task_context.py @@ -0,0 +1,24 @@ +"""Companion examples for src/mcp/server/experimental/task_context.py docstrings.""" + +from __future__ import annotations + +from mcp.server.experimental.task_context import ServerTaskContext +from mcp.types import CallToolResult, TextContent + + +async def ServerTaskContext_usage(task: ServerTaskContext) -> None: + # region ServerTaskContext_usage + async def my_task_work(task: ServerTaskContext) -> CallToolResult: + await task.update_status("Starting...") + + result = await task.elicit( + message="Continue?", + requested_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}}, + ) + + if result.action == "accept" and result.content and result.content.get("ok"): + return CallToolResult(content=[TextContent(text="Done!")]) + else: + return CallToolResult(content=[TextContent(text="Cancelled")]) + + # endregion ServerTaskContext_usage diff --git a/examples/snippets/docstrings/mcp/server/experimental/task_support.py b/examples/snippets/docstrings/mcp/server/experimental/task_support.py new file mode 100644 index 000000000..057d44069 --- /dev/null +++ b/examples/snippets/docstrings/mcp/server/experimental/task_support.py @@ -0,0 +1,33 @@ +"""Companion examples for src/mcp/server/experimental/task_support.py docstrings.""" + +from __future__ import annotations + +from typing import Any + +from mcp.server.lowlevel.server import Server +from mcp.shared.experimental.tasks.message_queue import TaskMessageQueue +from mcp.shared.experimental.tasks.store import TaskStore + + +# Stubs for undefined references in examples +class RedisTaskStore(TaskStore): # type: ignore[abstract] + def __init__(self, redis_url: str) -> None: ... + + +class RedisTaskMessageQueue(TaskMessageQueue): # type: ignore[abstract] + def __init__(self, redis_url: str) -> None: ... + + +def TaskSupport_simple(server: Server[Any]) -> None: + # region TaskSupport_simple + server.experimental.enable_tasks() + # endregion TaskSupport_simple + + +def TaskSupport_custom(server: Server[Any], redis_url: str) -> None: + # region TaskSupport_custom + server.experimental.enable_tasks( + store=RedisTaskStore(redis_url), + queue=RedisTaskMessageQueue(redis_url), + ) + # endregion TaskSupport_custom diff --git a/examples/snippets/docstrings/mcp/server/lowlevel/experimental.py b/examples/snippets/docstrings/mcp/server/lowlevel/experimental.py new file mode 100644 index 000000000..82dfcae0d --- /dev/null +++ b/examples/snippets/docstrings/mcp/server/lowlevel/experimental.py @@ -0,0 +1,32 @@ +"""Companion examples for src/mcp/server/lowlevel/experimental.py docstrings.""" + +from __future__ import annotations + +from typing import Any + +from mcp.server.lowlevel.server import Server +from mcp.shared.experimental.tasks.message_queue import TaskMessageQueue +from mcp.shared.experimental.tasks.store import TaskStore + + +class RedisTaskStore(TaskStore): # type: ignore[abstract] + def __init__(self, redis_url: str) -> None: ... + + +class RedisTaskMessageQueue(TaskMessageQueue): # type: ignore[abstract] + def __init__(self, redis_url: str) -> None: ... + + +def ExperimentalHandlers_enable_tasks_simple(server: Server[Any]) -> None: + # region ExperimentalHandlers_enable_tasks_simple + server.experimental.enable_tasks() + # endregion ExperimentalHandlers_enable_tasks_simple + + +def ExperimentalHandlers_enable_tasks_custom(server: Server[Any], redis_url: str) -> None: + # region ExperimentalHandlers_enable_tasks_custom + server.experimental.enable_tasks( + store=RedisTaskStore(redis_url), + queue=RedisTaskMessageQueue(redis_url), + ) + # endregion ExperimentalHandlers_enable_tasks_custom diff --git a/examples/snippets/docstrings/mcp/server/mcpserver/server.py b/examples/snippets/docstrings/mcp/server/mcpserver/server.py new file mode 100644 index 000000000..4b2e596e1 --- /dev/null +++ b/examples/snippets/docstrings/mcp/server/mcpserver/server.py @@ -0,0 +1,178 @@ +"""Companion examples for src/mcp/server/mcpserver/server.py docstrings.""" + +from __future__ import annotations + +from typing import Any, TypeAlias + +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +from mcp.server.mcpserver import Context, MCPServer +from mcp.types import ( + Completion, + CompletionArgument, + CompletionContext, + PromptReference, + ResourceTemplateReference, +) + +Message: TypeAlias = dict[str, Any] + + +async def fetch_data() -> str: ... +async def fetch_weather(city: str) -> str: ... +def read_table_schema(table_name: str) -> str: ... +async def read_file(path: str) -> str: ... + + +def MCPServer_tool_basic(server: MCPServer) -> None: + # region MCPServer_tool_basic + @server.tool() + def my_tool(x: int) -> str: + return str(x) + + # endregion MCPServer_tool_basic + + +def MCPServer_tool_with_context(server: MCPServer) -> None: + # region MCPServer_tool_with_context + @server.tool() + async def tool_with_context(x: int, ctx: Context) -> str: + await ctx.info(f"Processing {x}") + return str(x) + + # endregion MCPServer_tool_with_context + + +def MCPServer_tool_async(server: MCPServer) -> None: + # region MCPServer_tool_async + @server.tool() + async def async_tool(x: int, context: Context) -> str: + await context.report_progress(50, 100) + return str(x) + + # endregion MCPServer_tool_async + + +def MCPServer_completion(server: MCPServer) -> None: + # region MCPServer_completion + @server.completion() + async def handle_completion( + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, + ) -> Completion | None: + if isinstance(ref, ResourceTemplateReference): + # Return completions based on ref, argument, and context + return Completion(values=["option1", "option2"]) + return None + + # endregion MCPServer_completion + + +def MCPServer_resource_sync_static(server: MCPServer) -> None: + # region MCPServer_resource_sync_static + @server.resource("resource://my-resource") + def get_data() -> str: + return "Hello, world!" + + # endregion MCPServer_resource_sync_static + + +def MCPServer_resource_async_static(server: MCPServer) -> None: + # region MCPServer_resource_async_static + @server.resource("resource://my-resource") + async def get_data() -> str: + data = await fetch_data() + return f"Hello, world! {data}" + + # endregion MCPServer_resource_async_static + + +def MCPServer_resource_sync_template(server: MCPServer) -> None: + # region MCPServer_resource_sync_template + @server.resource("resource://{city}/weather") + def get_weather(city: str) -> str: + return f"Weather for {city}" + + # endregion MCPServer_resource_sync_template + + +def MCPServer_resource_async_template(server: MCPServer) -> None: + # region MCPServer_resource_async_template + @server.resource("resource://{city}/weather") + async def get_weather(city: str) -> str: + data = await fetch_weather(city) + return f"Weather for {city}: {data}" + + # endregion MCPServer_resource_async_template + + +def MCPServer_prompt_sync(server: MCPServer) -> None: + # region MCPServer_prompt_sync + @server.prompt() + def analyze_table(table_name: str) -> list[Message]: + schema = read_table_schema(table_name) + return [ + { + "role": "user", + "content": f"Analyze this schema:\n{schema}", + } + ] + + # endregion MCPServer_prompt_sync + + +def MCPServer_prompt_async(server: MCPServer) -> None: + # region MCPServer_prompt_async + @server.prompt() + async def analyze_file(path: str) -> list[Message]: + content = await read_file(path) + return [ + { + "role": "user", + "content": { + "type": "resource", + "resource": { + "uri": f"file://{path}", + "text": content, + }, + }, + } + ] + + # endregion MCPServer_prompt_async + + +def MCPServer_custom_route(server: MCPServer) -> None: + # region MCPServer_custom_route + @server.custom_route("/health", methods=["GET"]) + async def health_check(request: Request) -> Response: + return JSONResponse({"status": "ok"}) + + # endregion MCPServer_custom_route + + +def Context_usage(server: MCPServer) -> None: + # region Context_usage + @server.tool() + async def my_tool(x: int, ctx: Context) -> str: + # Log messages to the client + await ctx.info(f"Processing {x}") + await ctx.debug("Debug info") + await ctx.warning("Warning message") + await ctx.error("Error message") + + # Report progress + await ctx.report_progress(50, 100) + + # Access resources + data = await ctx.read_resource("resource://data") + + # Get request info + request_id = ctx.request_id + client_id = ctx.client_id + + return str(x) + + # endregion Context_usage diff --git a/examples/snippets/docstrings/mcp/server/sse.py b/examples/snippets/docstrings/mcp/server/sse.py new file mode 100644 index 000000000..1f5036f45 --- /dev/null +++ b/examples/snippets/docstrings/mcp/server/sse.py @@ -0,0 +1,42 @@ +"""Companion examples for src/mcp/server/sse.py docstrings.""" + +from __future__ import annotations + +from typing import Any + +import uvicorn +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import Response +from starlette.routing import Mount, Route + +from mcp.server.lowlevel.server import Server +from mcp.server.sse import SseServerTransport + + +def module_overview(app: Server[Any], port: int) -> None: + # region module_overview + # Create an SSE transport at an endpoint + sse = SseServerTransport("/messages/") + + # Define handler functions + async def handle_sse(request: Request) -> Response: + async with sse.connect_sse( + request.scope, + request.receive, + request._send, + ) as streams: + await app.run(streams[0], streams[1], app.create_initialization_options()) + # Return empty response to avoid NoneType error + return Response() + + # Create Starlette routes for SSE and message handling + routes = [ + Route("/sse", endpoint=handle_sse, methods=["GET"]), + Mount("/messages/", app=sse.handle_post_message), + ] + + # Create and run Starlette app + starlette_app = Starlette(routes=routes) + uvicorn.run(starlette_app, host="127.0.0.1", port=port) + # endregion module_overview diff --git a/examples/snippets/docstrings/mcp/server/stdio.py b/examples/snippets/docstrings/mcp/server/stdio.py new file mode 100644 index 000000000..d3ccab019 --- /dev/null +++ b/examples/snippets/docstrings/mcp/server/stdio.py @@ -0,0 +1,28 @@ +"""Companion examples for src/mcp/server/stdio.py docstrings.""" + +from __future__ import annotations + +from typing import Any + +import anyio + +from mcp.server.lowlevel.server import Server +from mcp.server.models import InitializationOptions +from mcp.server.stdio import stdio_server + + +# Stubs for undefined references in examples +async def create_my_server() -> Server[Any]: ... + + +def module_overview(init_options: InitializationOptions) -> None: + # region module_overview + async def run_server(): + async with stdio_server() as (read_stream, write_stream): + # read_stream contains incoming JSONRPCMessages from stdin + # write_stream allows sending JSONRPCMessages to stdout + server = await create_my_server() + await server.run(read_stream, write_stream, init_options) + + anyio.run(run_server) + # endregion module_overview diff --git a/examples/snippets/docstrings/mcp/shared/_httpx_utils.py b/examples/snippets/docstrings/mcp/shared/_httpx_utils.py new file mode 100644 index 000000000..7531f4683 --- /dev/null +++ b/examples/snippets/docstrings/mcp/shared/_httpx_utils.py @@ -0,0 +1,40 @@ +"""Companion examples for src/mcp/shared/_httpx_utils.py docstrings.""" + +from __future__ import annotations + +import httpx + +from mcp.shared._httpx_utils import create_mcp_http_client + + +async def create_mcp_http_client_basic() -> None: + # region create_mcp_http_client_basic + async with create_mcp_http_client() as client: + response = await client.get("https://api.example.com") + # endregion create_mcp_http_client_basic + + +async def create_mcp_http_client_headers() -> None: + # region create_mcp_http_client_headers + headers = {"Authorization": "Bearer token"} + async with create_mcp_http_client(headers) as client: + response = await client.get("/endpoint") + # endregion create_mcp_http_client_headers + + +async def create_mcp_http_client_timeout(headers: dict[str, str]) -> None: + # region create_mcp_http_client_timeout + timeout = httpx.Timeout(60.0, read=300.0) + async with create_mcp_http_client(headers, timeout) as client: + response = await client.get("/long-request") + # endregion create_mcp_http_client_timeout + + +async def create_mcp_http_client_auth(headers: dict[str, str], timeout: httpx.Timeout) -> None: + # region create_mcp_http_client_auth + from httpx import BasicAuth + + auth = BasicAuth(username="user", password="pass") + async with create_mcp_http_client(headers, timeout, auth) as client: + response = await client.get("/protected-endpoint") + # endregion create_mcp_http_client_auth diff --git a/examples/snippets/docstrings/mcp/shared/exceptions.py b/examples/snippets/docstrings/mcp/shared/exceptions.py new file mode 100644 index 000000000..6640916d3 --- /dev/null +++ b/examples/snippets/docstrings/mcp/shared/exceptions.py @@ -0,0 +1,20 @@ +"""Companion examples for src/mcp/shared/exceptions.py docstrings.""" + +from __future__ import annotations + +from mcp.shared.exceptions import UrlElicitationRequiredError +from mcp.types import ElicitRequestURLParams + + +def UrlElicitationRequiredError_usage() -> None: + # region UrlElicitationRequiredError_usage + raise UrlElicitationRequiredError( + [ + ElicitRequestURLParams( + message="Authorization required for your files", + url="https://example.com/oauth/authorize", + elicitation_id="auth-001", + ) + ] + ) + # endregion UrlElicitationRequiredError_usage diff --git a/examples/snippets/docstrings/mcp/shared/experimental/tasks/helpers.py b/examples/snippets/docstrings/mcp/shared/experimental/tasks/helpers.py new file mode 100644 index 000000000..e5b0483ee --- /dev/null +++ b/examples/snippets/docstrings/mcp/shared/experimental/tasks/helpers.py @@ -0,0 +1,17 @@ +"""Companion examples for src/mcp/shared/experimental/tasks/helpers.py docstrings.""" + +from __future__ import annotations + +from typing import Any + +from mcp.shared.experimental.tasks.helpers import cancel_task +from mcp.shared.experimental.tasks.store import TaskStore +from mcp.types import CancelTaskRequestParams, CancelTaskResult + + +def cancel_task_usage(store: TaskStore) -> None: + # region cancel_task_usage + async def handle_cancel(ctx: Any, params: CancelTaskRequestParams) -> CancelTaskResult: + return await cancel_task(store, params.task_id) + + # endregion cancel_task_usage diff --git a/examples/snippets/docstrings/mcp/shared/metadata_utils.py b/examples/snippets/docstrings/mcp/shared/metadata_utils.py new file mode 100644 index 000000000..6b1dabe71 --- /dev/null +++ b/examples/snippets/docstrings/mcp/shared/metadata_utils.py @@ -0,0 +1,16 @@ +"""Companion examples for src/mcp/shared/metadata_utils.py docstrings.""" + +from __future__ import annotations + +from mcp.client.session import ClientSession +from mcp.shared.metadata_utils import get_display_name + + +async def get_display_name_usage(session: ClientSession) -> None: + # region get_display_name_usage + # In a client displaying available tools + tools = await session.list_tools() + for tool in tools.tools: + display_name = get_display_name(tool) + print(f"Available tool: {display_name}") + # endregion get_display_name_usage diff --git a/examples/snippets/docstrings/mcp/shared/response_router.py b/examples/snippets/docstrings/mcp/shared/response_router.py new file mode 100644 index 000000000..e8870dd65 --- /dev/null +++ b/examples/snippets/docstrings/mcp/shared/response_router.py @@ -0,0 +1,24 @@ +"""Companion examples for src/mcp/shared/response_router.py docstrings.""" + +from __future__ import annotations + +from typing import Any + +from mcp.shared.experimental.tasks.resolver import Resolver +from mcp.shared.response_router import ResponseRouter +from mcp.types import RequestId + + +def ResponseRouter_usage() -> None: + # region ResponseRouter_usage + class TaskResultHandler(ResponseRouter): + _pending_requests: dict[RequestId, Resolver[dict[str, Any]]] + + def route_response(self, request_id: Any, response: Any) -> bool: + resolver = self._pending_requests.pop(request_id, None) + if resolver: + resolver.set_result(response) + return True + return False + + # endregion ResponseRouter_usage diff --git a/examples/snippets/docstrings/mcp/shared/session.py b/examples/snippets/docstrings/mcp/shared/session.py new file mode 100644 index 000000000..a5cd7f994 --- /dev/null +++ b/examples/snippets/docstrings/mcp/shared/session.py @@ -0,0 +1,14 @@ +"""Companion examples for src/mcp/shared/session.py docstrings.""" + +from __future__ import annotations + +from typing import Any + +from mcp.shared.session import RequestResponder + + +async def RequestResponder_usage(request_responder: RequestResponder[Any, Any], result: Any) -> None: + # region RequestResponder_usage + with request_responder as resp: + await resp.respond(result) + # endregion RequestResponder_usage diff --git a/examples/snippets/pyproject.toml b/examples/snippets/pyproject.toml index 4e68846a0..e5bccb38b 100644 --- a/examples/snippets/pyproject.toml +++ b/examples/snippets/pyproject.toml @@ -11,8 +11,8 @@ dependencies = [ requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta" -[tool.setuptools] -packages = ["servers", "clients"] +[tool.setuptools.packages.find] +where = ["."] [project.scripts] server = "servers:run_server" diff --git a/pyproject.toml b/pyproject.toml index 737839a23..b73231b85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,6 +120,7 @@ executionEnvironments = [ ".", ], reportUnusedFunction = false, reportPrivateUsage = false }, { root = "examples/servers", reportUnusedFunction = false }, + { root = "examples/snippets/docstrings", reportUnusedFunction = false, reportUnusedVariable = false, reportAbstractUsage = false, reportUnusedClass = false, reportPrivateUsage = false }, ] [tool.ruff] @@ -152,6 +153,7 @@ max-complexity = 24 # Default is 10 "__init__.py" = ["F401"] "tests/server/mcpserver/test_func_metadata.py" = ["E501"] "tests/shared/test_progress_notifications.py" = ["PLW0603"] +"examples/snippets/docstrings/**/*.py" = ["F821", "F841"] [tool.ruff.lint.pylint] allow-magic-value-types = ["bytes", "float", "int", "str"] diff --git a/src/mcp/client/auth/extensions/client_credentials.py b/src/mcp/client/auth/extensions/client_credentials.py index cb6dafb40..3b87676b0 100644 --- a/src/mcp/client/auth/extensions/client_credentials.py +++ b/src/mcp/client/auth/extensions/client_credentials.py @@ -28,6 +28,7 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider): Use this when you already have client credentials (client_id and client_secret). Example: + ```python provider = ClientCredentialsOAuthProvider( server_url="https://api.example.com", @@ -36,6 +37,7 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider): client_secret="my-client-secret", ) ``` + """ def __init__( @@ -114,6 +116,7 @@ def static_assertion_provider(token: str) -> Callable[[str], Awaitable[str]]: that doesn't need the audience parameter. Example: + ```python provider = PrivateKeyJWTOAuthProvider( server_url="https://api.example.com", @@ -122,6 +125,7 @@ def static_assertion_provider(token: str) -> Callable[[str], Awaitable[str]]: assertion_provider=static_assertion_provider(my_prebuilt_jwt), ) ``` + Args: token: The pre-built JWT assertion string. @@ -143,6 +147,7 @@ class SignedJWTParameters(BaseModel): for use with `PrivateKeyJWTOAuthProvider`. Example: + ```python jwt_params = SignedJWTParameters( issuer="my-client-id", @@ -156,6 +161,7 @@ class SignedJWTParameters(BaseModel): assertion_provider=jwt_params.create_assertion_provider(), ) ``` + """ issuer: str = Field(description="Issuer for JWT assertions (typically client_id).") @@ -205,6 +211,7 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider): In production scenarios, the JWT assertion is typically obtained from a workload identity provider (e.g., GCP, AWS IAM, Azure AD): + ```python async def get_workload_identity_token(audience: str) -> str: # Fetch JWT from your identity provider @@ -218,11 +225,13 @@ async def get_workload_identity_token(audience: str) -> str: assertion_provider=get_workload_identity_token, ) ``` + **Option 2: Static pre-built JWT** If you have a static JWT that doesn't need the audience parameter: + ```python provider = PrivateKeyJWTOAuthProvider( server_url="https://api.example.com", @@ -231,11 +240,13 @@ async def get_workload_identity_token(audience: str) -> str: assertion_provider=static_assertion_provider(my_prebuilt_jwt), ) ``` + **Option 3: SDK-signed JWT (for testing/simple setups)** For testing or simple deployments, use `SignedJWTParameters.create_assertion_provider()`: + ```python jwt_params = SignedJWTParameters( issuer="my-client-id", @@ -249,6 +260,7 @@ async def get_workload_identity_token(audience: str) -> str: assertion_provider=jwt_params.create_assertion_provider(), ) ``` + """ def __init__( diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index 7dc67c584..d5f1ca1ea 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -41,6 +41,7 @@ class Client: Streamable HTTP transport (pass a URL string), or a custom Transport instance. Example: + ```python from mcp.client import Client from mcp.server.mcpserver import MCPServer @@ -57,6 +58,7 @@ async def main(): asyncio.run(main()) ``` + """ server: Server[Any] | MCPServer | Transport | str diff --git a/src/mcp/client/experimental/task_handlers.py b/src/mcp/client/experimental/task_handlers.py index 0ab513236..b9aab16f2 100644 --- a/src/mcp/client/experimental/task_handlers.py +++ b/src/mcp/client/experimental/task_handlers.py @@ -187,13 +187,15 @@ class ExperimentalTaskHandlers: WARNING: These APIs are experimental and may change without notice. Example: + ```python handlers = ExperimentalTaskHandlers( get_task=my_get_task_handler, list_tasks=my_list_tasks_handler, ) - session = ClientSession(..., experimental_task_handlers=handlers) + session = ClientSession(read_stream, write_stream, experimental_task_handlers=handlers) ``` + """ # Pure task request handlers diff --git a/src/mcp/client/experimental/tasks.py b/src/mcp/client/experimental/tasks.py index a566df766..b9298269f 100644 --- a/src/mcp/client/experimental/tasks.py +++ b/src/mcp/client/experimental/tasks.py @@ -5,6 +5,7 @@ WARNING: These APIs are experimental and may change without notice. Example: + ```python # Call a tool as a task result = await session.experimental.call_tool_as_task("tool_name", {"arg": "value"}) @@ -23,6 +24,7 @@ # Cancel a task await session.experimental.cancel_task(task_id) ``` + """ from collections.abc import AsyncIterator @@ -74,11 +76,10 @@ async def call_tool_as_task( CreateTaskResult containing the task reference Example: + ```python # Create task - result = await session.experimental.call_tool_as_task( - "long_running_tool", {"input": "data"} - ) + result = await session.experimental.call_tool_as_task("long_running_tool", {"input": "data"}) task_id = result.task.task_id # Poll for completion @@ -91,6 +92,7 @@ async def call_tool_as_task( # Get result final = await session.experimental.get_task_result(task_id, CallToolResult) ``` + """ return await self._session.send_request( types.CallToolRequest( @@ -193,6 +195,7 @@ async def poll_task(self, task_id: str) -> AsyncIterator[types.GetTaskResult]: GetTaskResult for each poll Example: + ```python async for status in session.experimental.poll_task(task_id): print(f"Status: {status.status}") @@ -203,6 +206,7 @@ async def poll_task(self, task_id: str) -> AsyncIterator[types.GetTaskResult]: # Task is now terminal, get the result result = await session.experimental.get_task_result(task_id, CallToolResult) ``` + """ async for status in poll_until_terminal(self.get_task, task_id): yield status diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index a0ca751bd..cb8898286 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -206,10 +206,12 @@ def experimental(self) -> ExperimentalClientFeatures: These APIs are experimental and may change without notice. Example: + ```python status = await session.experimental.get_task(task_id) result = await session.experimental.get_task_result(task_id, CallToolResult) ``` + """ if self._experimental_features is None: self._experimental_features = ExperimentalClientFeatures(self) diff --git a/src/mcp/client/session_group.py b/src/mcp/client/session_group.py index 961021264..b0b8a6fcf 100644 --- a/src/mcp/client/session_group.py +++ b/src/mcp/client/session_group.py @@ -92,13 +92,17 @@ class ClientSessionGroup: the client and can be accessed via the session. Example: + ```python - name_fn = lambda name, server_info: f"{(server_info.name)}_{name}" + def name_fn(name: str, server_info: Any) -> str: + return f"{server_info.name}_{name}" + async with ClientSessionGroup(component_name_hook=name_fn) as group: for server_param in server_params: await group.connect_to_server(server_param) ... ``` + """ class _ComponentNames(BaseModel): diff --git a/src/mcp/server/experimental/request_context.py b/src/mcp/server/experimental/request_context.py index 3eba65822..f30cd957d 100644 --- a/src/mcp/server/experimental/request_context.py +++ b/src/mcp/server/experimental/request_context.py @@ -160,18 +160,26 @@ async def run_task( RuntimeError: If task support is not enabled or task_metadata is missing Example: + ```python - async def handle_tool(ctx: RequestContext, params: CallToolRequestParams) -> CallToolResult: + async def handle_tool( + ctx: ServerRequestContext[Any, Any], + params: CallToolRequestParams, + ) -> CreateTaskResult: async def work(task: ServerTaskContext) -> CallToolResult: result = await task.elicit( message="Are you sure?", - requested_schema={"type": "object", ...} + requested_schema={"type": "object", "properties": {"confirm": {"type": "boolean"}}}, ) - confirmed = result.content.get("confirm", False) + if result.action == "accept" and result.content: + confirmed = result.content.get("confirm", False) + else: + confirmed = False return CallToolResult(content=[TextContent(text="Done" if confirmed else "Cancelled")]) return await ctx.experimental.run_task(work) ``` + WARNING: This API is experimental and may change without notice. """ diff --git a/src/mcp/server/experimental/task_context.py b/src/mcp/server/experimental/task_context.py index 1fc45badf..72c1f81df 100644 --- a/src/mcp/server/experimental/task_context.py +++ b/src/mcp/server/experimental/task_context.py @@ -56,20 +56,22 @@ class ServerTaskContext: - Status notifications via the session Example: + ```python async def my_task_work(task: ServerTaskContext) -> CallToolResult: await task.update_status("Starting...") result = await task.elicit( message="Continue?", - requested_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}} + requested_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}}, ) - if result.content.get("ok"): + if result.action == "accept" and result.content and result.content.get("ok"): return CallToolResult(content=[TextContent(text="Done!")]) else: return CallToolResult(content=[TextContent(text="Cancelled")]) ``` + """ def __init__( diff --git a/src/mcp/server/experimental/task_support.py b/src/mcp/server/experimental/task_support.py index b54219504..bd437fc4f 100644 --- a/src/mcp/server/experimental/task_support.py +++ b/src/mcp/server/experimental/task_support.py @@ -33,18 +33,22 @@ class TaskSupport: Example: Simple in-memory setup: + ```python server.experimental.enable_tasks() ``` + Custom store/queue for distributed systems: + ```python server.experimental.enable_tasks( store=RedisTaskStore(redis_url), queue=RedisTaskMessageQueue(redis_url), ) ``` + """ store: TaskStore diff --git a/src/mcp/server/lowlevel/experimental.py b/src/mcp/server/lowlevel/experimental.py index 5a907b640..e12442076 100644 --- a/src/mcp/server/lowlevel/experimental.py +++ b/src/mcp/server/lowlevel/experimental.py @@ -120,18 +120,22 @@ def enable_tasks( Example: Simple in-memory setup: + ```python server.experimental.enable_tasks() ``` + Custom store/queue for distributed systems: + ```python server.experimental.enable_tasks( store=RedisTaskStore(redis_url), queue=RedisTaskMessageQueue(redis_url), ) ``` + WARNING: This API is experimental and may change without notice. """ diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 9c7105a7b..d17e8a901 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -535,25 +535,31 @@ def tool( - If False, unconditionally creates an unstructured tool Example: + ```python @server.tool() def my_tool(x: int) -> str: return str(x) ``` + + ```python @server.tool() async def tool_with_context(x: int, ctx: Context) -> str: await ctx.info(f"Processing {x}") return str(x) ``` + + ```python @server.tool() async def async_tool(x: int, context: Context) -> str: await context.report_progress(50, 100) return str(x) ``` + """ # Check if user passed function directly instead of calling decorator if callable(name): @@ -585,14 +591,20 @@ def completion(self): - context: Optional CompletionContext with previously resolved arguments Example: + ```python - @mcp.completion() - async def handle_completion(ref, argument, context): + @server.completion() + async def handle_completion( + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, + ) -> Completion | None: if isinstance(ref, ResourceTemplateReference): # Return completions based on ref, argument, and context return Completion(values=["option1", "option2"]) return None ``` + """ def decorator(func: _CallableT) -> _CallableT: @@ -655,25 +667,39 @@ def resource( meta: Optional metadata dictionary for the resource Example: + ```python @server.resource("resource://my-resource") def get_data() -> str: return "Hello, world!" + ``` + + + ```python @server.resource("resource://my-resource") async def get_data() -> str: data = await fetch_data() return f"Hello, world! {data}" + ``` + + + ```python @server.resource("resource://{city}/weather") def get_weather(city: str) -> str: return f"Weather for {city}" + ``` + + + ```python @server.resource("resource://{city}/weather") async def get_weather(city: str) -> str: data = await fetch_weather(city) return f"Weather for {city}: {data}" ``` + """ # Check if user passed function directly instead of calling decorator if callable(uri): @@ -757,6 +783,7 @@ def prompt( icons: Optional list of icons for the prompt Example: + ```python @server.prompt() def analyze_table(table_name: str) -> list[Message]: @@ -764,10 +791,14 @@ def analyze_table(table_name: str) -> list[Message]: return [ { "role": "user", - "content": f"Analyze this schema:\n{schema}" + "content": f"Analyze this schema:\n{schema}", } ] + ``` + + + ```python @server.prompt() async def analyze_file(path: str) -> list[Message]: content = await read_file(path) @@ -778,12 +809,13 @@ async def analyze_file(path: str) -> list[Message]: "type": "resource", "resource": { "uri": f"file://{path}", - "text": content - } - } + "text": content, + }, + }, } ] ``` + """ # Check if user passed function directly instead of calling decorator if callable(name): @@ -825,11 +857,13 @@ def custom_route( include_in_schema: Whether to include in OpenAPI schema, defaults to True Example: + ```python @server.custom_route("/health", methods=["GET"]) async def health_check(request: Request) -> Response: return JSONResponse({"status": "ok"}) ``` + """ def decorator( # pragma: no cover @@ -1113,6 +1147,7 @@ class Context(BaseModel, Generic[LifespanContextT, RequestT]): To use context in a tool function, add a parameter with the Context type annotation: + ```python @server.tool() async def my_tool(x: int, ctx: Context) -> str: @@ -1134,6 +1169,7 @@ async def my_tool(x: int, ctx: Context) -> str: return str(x) ``` + The context parameter name can be anything as long as it's annotated with Context. The context is optional - tools that don't need it can omit the parameter. diff --git a/src/mcp/server/sse.py b/src/mcp/server/sse.py index 9007230ce..b557fbc62 100644 --- a/src/mcp/server/sse.py +++ b/src/mcp/server/sse.py @@ -3,31 +3,33 @@ This module implements a Server-Sent Events (SSE) transport layer for MCP servers. Example: + ```python # Create an SSE transport at an endpoint sse = SseServerTransport("/messages/") - # Create Starlette routes for SSE and message handling - routes = [ - Route("/sse", endpoint=handle_sse, methods=["GET"]), - Mount("/messages/", app=sse.handle_post_message), - ] - # Define handler functions - async def handle_sse(request): + async def handle_sse(request: Request) -> Response: async with sse.connect_sse( - request.scope, request.receive, request._send + request.scope, + request.receive, + request._send, ) as streams: - await app.run( - streams[0], streams[1], app.create_initialization_options() - ) + await app.run(streams[0], streams[1], app.create_initialization_options()) # Return empty response to avoid NoneType error return Response() + # Create Starlette routes for SSE and message handling + routes = [ + Route("/sse", endpoint=handle_sse, methods=["GET"]), + Mount("/messages/", app=sse.handle_post_message), + ] + # Create and run Starlette app starlette_app = Starlette(routes=routes) uvicorn.run(starlette_app, host="127.0.0.1", port=port) ``` + Note: The handle_sse function must return a Response to avoid a "TypeError: 'NoneType' object is not callable" error when client disconnects. The example above returns diff --git a/src/mcp/server/stdio.py b/src/mcp/server/stdio.py index e526bab56..ab010677d 100644 --- a/src/mcp/server/stdio.py +++ b/src/mcp/server/stdio.py @@ -5,6 +5,7 @@ streams. Example: + ```python async def run_server(): async with stdio_server() as (read_stream, write_stream): @@ -15,6 +16,7 @@ async def run_server(): anyio.run(run_server) ``` + """ import sys diff --git a/src/mcp/shared/_httpx_utils.py b/src/mcp/shared/_httpx_utils.py index 251469eaa..e0ec2aa09 100644 --- a/src/mcp/shared/_httpx_utils.py +++ b/src/mcp/shared/_httpx_utils.py @@ -47,35 +47,44 @@ def create_mcp_http_client( Example: Basic usage with MCP defaults: + ```python async with create_mcp_http_client() as client: response = await client.get("https://api.example.com") ``` + With custom headers: + ```python headers = {"Authorization": "Bearer token"} async with create_mcp_http_client(headers) as client: response = await client.get("/endpoint") ``` + With both custom headers and timeout: + ```python timeout = httpx.Timeout(60.0, read=300.0) async with create_mcp_http_client(headers, timeout) as client: response = await client.get("/long-request") ``` + With authentication: + ```python from httpx import BasicAuth + auth = BasicAuth(username="user", password="pass") async with create_mcp_http_client(headers, timeout, auth) as client: response = await client.get("/protected-endpoint") ``` + """ # Set MCP defaults kwargs: dict[str, Any] = {"follow_redirects": True} diff --git a/src/mcp/shared/exceptions.py b/src/mcp/shared/exceptions.py index f153ea319..9b1df452a 100644 --- a/src/mcp/shared/exceptions.py +++ b/src/mcp/shared/exceptions.py @@ -65,15 +65,19 @@ class UrlElicitationRequiredError(MCPError): must complete one or more URL elicitations before the request can be processed. Example: + ```python - raise UrlElicitationRequiredError([ - ElicitRequestURLParams( - message="Authorization required for your files", - url="https://example.com/oauth/authorize", - elicitation_id="auth-001" - ) - ]) + raise UrlElicitationRequiredError( + [ + ElicitRequestURLParams( + message="Authorization required for your files", + url="https://example.com/oauth/authorize", + elicitation_id="auth-001", + ) + ] + ) ``` + """ def __init__(self, elicitations: list[ElicitRequestURLParams], message: str | None = None): diff --git a/src/mcp/shared/experimental/tasks/helpers.py b/src/mcp/shared/experimental/tasks/helpers.py index 3f91cd0d0..4032172ce 100644 --- a/src/mcp/shared/experimental/tasks/helpers.py +++ b/src/mcp/shared/experimental/tasks/helpers.py @@ -72,10 +72,12 @@ async def cancel_task( - Task is already in a terminal state (completed, failed, cancelled) Example: + ```python - async def handle_cancel(ctx, params: CancelTaskRequestParams) -> CancelTaskResult: + async def handle_cancel(ctx: Any, params: CancelTaskRequestParams) -> CancelTaskResult: return await cancel_task(store, params.task_id) ``` + """ task = await store.get_task(task_id) if task is None: diff --git a/src/mcp/shared/metadata_utils.py b/src/mcp/shared/metadata_utils.py index 6e4d33da0..77330142c 100644 --- a/src/mcp/shared/metadata_utils.py +++ b/src/mcp/shared/metadata_utils.py @@ -18,6 +18,7 @@ def get_display_name(obj: Tool | Resource | Prompt | ResourceTemplate | Implemen For other objects: title > name Example: + ```python # In a client displaying available tools tools = await session.list_tools() @@ -25,6 +26,7 @@ def get_display_name(obj: Tool | Resource | Prompt | ResourceTemplate | Implemen display_name = get_display_name(tool) print(f"Available tool: {display_name}") ``` + Args: obj: An MCP object with name and optional title fields diff --git a/src/mcp/shared/response_router.py b/src/mcp/shared/response_router.py index fe24b016f..b8652d9bb 100644 --- a/src/mcp/shared/response_router.py +++ b/src/mcp/shared/response_router.py @@ -25,15 +25,19 @@ class ResponseRouter(Protocol): and deliver the response/error to the appropriate handler. Example: + ```python class TaskResultHandler(ResponseRouter): - def route_response(self, request_id, response): + _pending_requests: dict[RequestId, Resolver[dict[str, Any]]] + + def route_response(self, request_id: Any, response: Any) -> bool: resolver = self._pending_requests.pop(request_id, None) if resolver: resolver.set_result(response) return True return False ``` + """ def route_response(self, request_id: RequestId, response: dict[str, Any]) -> bool: diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index b617d702f..f01db461f 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -60,10 +60,12 @@ class RequestResponder(Generic[ReceiveRequestT, SendResultT]): cancellation handling: Example: + ```python with request_responder as resp: await resp.respond(result) ``` + The context manager ensures: 1. Proper cancellation scope setup and cleanup From 840e5db88756b78b848a5bee2eb392fa026079f8 Mon Sep 17 00:00:00 2001 From: Jonathan Hefner Date: Fri, 20 Feb 2026 14:29:42 -0600 Subject: [PATCH 4/6] Document docstring snippet conventions in `CLAUDE.md` Add a "Docstring Code Examples" section covering the companion file system: directory layout, `` markers, `ClassName_methodName_variant` naming, the function-parameter pattern for typed dependencies, and the prohibition on type-suppression comments inside regions. --- CLAUDE.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index e48ce6e70..32e2389a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,6 +109,55 @@ rather than adding new standalone sections. - Update config rev - Commit config first +## Docstring Code Examples + +Code examples in `src/mcp/` docstrings are type-checked via companion files in +`examples/snippets/docstrings/mcp/`, mirroring the source tree +(`src/mcp/foo/bar.py` → `examples/snippets/docstrings/mcp/foo/bar.py`). +Companion files are standalone scripts (not packages) starting with +`from __future__ import annotations`. The companion file is the source of truth — +always edit examples there, never directly in the docstring. + +Each example lives in a named function (returning `-> None`) wrapping +`# region Name` / `# endregion Name` markers. Names follow +`ClassName_methodName_variant` for methods, `functionName_variant` for standalone +functions, or `module_overview` for module docstrings. Pick a descriptive variant +suffix (`_basic`, `_sync`/`_async`, `_with_context`, etc.). In the companion file: + +````python +def MyClass_do_thing_basic(obj: MyClass) -> None: + # region MyClass_do_thing_basic + result = obj.do_thing("arg") + print(result) + # endregion MyClass_do_thing_basic +```` + +The sync script wraps region content in a fenced code block between +`` and `` markers. +In the source docstring: + +```` + Example: + + ```python + result = obj.do_thing("arg") + print(result) + ``` + +```` + +Function parameters supply typed dependencies the example needs but does not create +(e.g., `server: MCPServer`); module-level stubs are only for truly undefined references +(e.g., `async def fetch_data() -> str: ...`). + +NEVER put `# type: ignore`, `# pyright: ignore`, or `# noqa` inside a region — these +sync verbatim into the docstring. Restructure the code or move problematic lines outside +the region instead. + +After editing a companion file, run `uv run --frozen pyright` to verify types, then +`uv run python scripts/sync_snippets.py` to sync into docstrings. Use `--check` to +verify sync without modifying files. + ## Error Resolution 1. CI Failures From 2224ba59989e3c28812600ae9151d41df88e38a5 Mon Sep 17 00:00:00 2001 From: Jonathan Hefner Date: Fri, 20 Feb 2026 15:23:23 -0600 Subject: [PATCH 5/6] Document markdown snippet syncing in `CLAUDE.md` `sync_snippets.py` also syncs snippet-source markers in `docs/**/*.md` and `README.v2.md`, not just `src/` docstrings. Add a short section after "Docstring Code Examples" noting that these files use explicit paths (path-less `#Region` markers are only supported in `src/` files). --- CLAUDE.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 32e2389a5..e808653db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,6 +158,20 @@ After editing a companion file, run `uv run --frozen pyright` to verify types, t `uv run python scripts/sync_snippets.py` to sync into docstrings. Use `--check` to verify sync without modifying files. +## Markdown Code Examples + +The `sync_snippets.py` script also syncs snippets to `docs/**/*.md` and `README.v2.md`. +These files use explicit paths with optional `#Region` markers for `snippet-source` +(path-less `#Region` markers are only supported in `src/` files): + +````markdown + +```python +# contents of examples/snippets/servers/foo.py +``` + +```` + ## Error Resolution 1. CI Failures From c0e1559985261f5dd7e22e131e8247923ef691f7 Mon Sep 17 00:00:00 2001 From: Jonathan Hefner Date: Fri, 20 Feb 2026 16:13:49 -0600 Subject: [PATCH 6/6] Restructure snippet system docs into shared intro with subsections The previous documentation had two flat sections where the docstring section contained all the detail and the markdown section referred to it vaguely. Reorganize into a `## Code Snippet System` intro covering shared concepts (marker format, region extraction, naming conventions, function wrappers, typed params, `# type: ignore` prohibition, pyright workflow) with `### Markdown Code Examples` and `### Docstring Code Examples` subsections covering only what is unique to each target type. --- CLAUDE.md | 77 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 40 insertions(+), 37 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e808653db..7dc1d712c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,20 +109,19 @@ rather than adding new standalone sections. - Update config rev - Commit config first -## Docstring Code Examples +## Code Snippet System -Code examples in `src/mcp/` docstrings are type-checked via companion files in -`examples/snippets/docstrings/mcp/`, mirroring the source tree -(`src/mcp/foo/bar.py` → `examples/snippets/docstrings/mcp/foo/bar.py`). -Companion files are standalone scripts (not packages) starting with -`from __future__ import annotations`. The companion file is the source of truth — -always edit examples there, never directly in the docstring. +`scripts/sync_snippets.py` replaces the content between +`` / `` markers with code +from the referenced source file. The source file is the source of truth — +never edit synced content directly in the target. -Each example lives in a named function (returning `-> None`) wrapping -`# region Name` / `# endregion Name` markers. Names follow -`ClassName_methodName_variant` for methods, `functionName_variant` for standalone -functions, or `module_overview` for module docstrings. Pick a descriptive variant -suffix (`_basic`, `_sync`/`_async`, `_with_context`, etc.). In the companion file: +To sync only part of a file, append `#RegionName` to the path. Regions are +delimited in source files by `# region Name` / `# endregion Name` markers. +Each example lives in a named function (returning `-> None`) wrapping a region. +Names follow `ClassName_methodName_variant` for methods, `functionName_variant` +for standalone functions, or `module_overview` for module docstrings. Pick a +descriptive variant suffix (`_basic`, `_sync`/`_async`, `_with_context`, etc.): ````python def MyClass_do_thing_basic(obj: MyClass) -> None: @@ -132,46 +131,50 @@ def MyClass_do_thing_basic(obj: MyClass) -> None: # endregion MyClass_do_thing_basic ```` -The sync script wraps region content in a fenced code block between -`` and `` markers. -In the source docstring: - -```` - Example: - - ```python - result = obj.do_thing("arg") - print(result) - ``` - -```` - Function parameters supply typed dependencies the example needs but does not create (e.g., `server: MCPServer`); module-level stubs are only for truly undefined references (e.g., `async def fetch_data() -> str: ...`). NEVER put `# type: ignore`, `# pyright: ignore`, or `# noqa` inside a region — these -sync verbatim into the docstring. Restructure the code or move problematic lines outside -the region instead. +sync verbatim into the target. Restructure the code to address the errors instead. -After editing a companion file, run `uv run --frozen pyright` to verify types, then -`uv run python scripts/sync_snippets.py` to sync into docstrings. Use `--check` to -verify sync without modifying files. +After editing an example file, run `uv run --frozen pyright` to verify types, then +`uv run python scripts/sync_snippets.py` to sync. Use `--check` to verify without +modifying files. -## Markdown Code Examples +### Markdown Code Examples -The `sync_snippets.py` script also syncs snippets to `docs/**/*.md` and `README.v2.md`. -These files use explicit paths with optional `#Region` markers for `snippet-source` -(path-less `#Region` markers are only supported in `src/` files): +Code examples in `README.v2.md` and `docs/**/*.md` use explicit paths relative +to the repo root: ````markdown - + ```python -# contents of examples/snippets/servers/foo.py +# replaced by sync script ``` ```` +### Docstring Code Examples + +Code examples in `src/` docstrings use companion files in +`examples/snippets/docstrings/`, mirroring the source tree +(`src/mcp/foo/bar.py` → `examples/snippets/docstrings/mcp/foo/bar.py`). +Companion files are standalone scripts (not packages) starting with +`from __future__ import annotations`. + +Docstrings use path-less `#Region` markers (only supported in `src/` files): + +````text + Example: + + ```python + result = obj.do_thing("arg") + print(result) + ``` + +```` + ## Error Resolution 1. CI Failures