commit 49b9bb6724e28e72d1357cd74b045f47246c06e2 Author: wehub-resource-sync Date: Mon Jul 13 12:10:27 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.claude/commands/review-pr.md b/.claude/commands/review-pr.md new file mode 100644 index 0000000..114c1e3 --- /dev/null +++ b/.claude/commands/review-pr.md @@ -0,0 +1,118 @@ +Review the pull request: $ARGUMENTS + +Follow these steps carefully. Use the `gh` CLI for all GitHub interactions. + +## Step 1: Resolve the PR + +Parse `$ARGUMENTS` to determine the PR. It can be: + +- A full URL like `https://github.com/owner/repo/pull/123` +- A `owner/repo#123` reference +- A bare number like `123` (use the current repo) +- A description — search for it with `gh pr list --search "" --limit 5` and pick the best match + +Once resolved, fetch the PR metadata: + +```bash +gh pr view --json number,title,body,author,state,baseRefName,headRefName,url,labels,milestone,additions,deletions,changedFiles,createdAt,updatedAt,mergedAt,reviewDecision,reviews,assignees +``` + +## Step 2: Gather the diff + +Get the full diff of the PR: + +```bash +gh pr diff +``` + +If the diff is very large (>3000 lines), focus on the most important files first and summarize the rest. + +## Step 3: Collect PR discussion context + +Fetch all comments and review threads: + +```bash +gh api repos/{owner}/{repo}/pulls/{number}/comments --paginate +gh api repos/{owner}/{repo}/issues/{number}/comments --paginate +gh api repos/{owner}/{repo}/pulls/{number}/reviews --paginate +``` + +Pay attention to: + +- Reviewer feedback and requested changes +- Author responses and explanations +- Any unresolved conversations +- Approval or rejection status + +## Step 4: Find and read linked issues + +Look for issue references in: + +- The PR body (patterns like `#123`, `fixes #123`, `closes #123`, `resolves #123`) +- The PR branch name (patterns like `issue-123`, `fix/123`) +- Commit messages + +For each linked issue, fetch its content: + +```bash +gh issue view --json title,body,comments,labels,state +``` + +Read through issue comments to understand the original problem, user reports, and any discussed solutions. + +## Step 5: Analyze and validate + +With all context gathered, analyze the PR critically: + +1. **Intent alignment**: Does the code change actually solve the problem described in the PR and/or linked issues? +2. **Completeness**: Are there aspects of the issue or requested feature that the PR doesn't address? +3. **Scope**: Does the PR include changes unrelated to the stated goal? Are there unnecessary modifications? +4. **Correctness**: Based on the diff, are there obvious bugs, edge cases, or logic errors? +5. **Testing**: Does the PR include tests? Are they meaningful and do they cover the important cases? +6. **Breaking changes**: Could this PR break existing functionality or APIs? +7. **Unresolved feedback**: Are there reviewer comments that haven't been addressed? + +## Step 6: Produce the review summary + +Present the summary in this format: + +--- + +### PR Review: `` (<url>) + +**Author:** <author> | **Status:** <state> | **Review decision:** <decision> +**Base:** `<base>` ← `<head>` | **Changed files:** <n> | **+<additions> / -<deletions>** + +#### Problem + +<1-3 sentences describing what problem this PR is trying to solve, based on the PR description and linked issues> + +#### Solution + +<1-3 sentences describing the approach taken in the code> + +#### Key changes + +<Bulleted list of the most important changes, grouped by theme. Include file paths.> + +#### Linked issues + +<List of linked issues with their title, state, and a one-line summary of the discussion> + +#### Discussion highlights + +<Summary of important comments from reviewers and the author. Flag any unresolved threads.> + +#### Concerns + +<List any issues found during validation: bugs, missing tests, scope creep, unaddressed feedback, etc. If none, say "No concerns found."> + +#### Verdict + +<One of: APPROVE / REQUEST CHANGES / NEEDS DISCUSSION, with a brief justification> + +#### Suggested action + +<Clear recommendation for the reviewer: what to approve, what to push back on, what to ask about> + +--- diff --git a/.claude/skills/test-quality/SKILL.md b/.claude/skills/test-quality/SKILL.md new file mode 100644 index 0000000..2815270 --- /dev/null +++ b/.claude/skills/test-quality/SKILL.md @@ -0,0 +1,150 @@ +--- +name: test-quality +description: Test quality bar for this repo. Read when writing, reviewing, or designing tests — covers naming, abstraction level, assertions, determinism, and the process for agent-driven test work. +--- + +# Test & code quality guide + +What "best practice for new work" means in this repo. Each rule carries its recorded reasoning +where one exists; a rule with no stated why is a convention — follow it anyway. + +## Naming & shape + +- **Test names are behaviour sentences** stating the observable outcome, not the feature being + poked: `test_elicit_form_decline_returns_no_content`, never `test_elicit_form_decline`. +- Plain top-level `test_*` functions; no `Test` classes (legacy files have them — don't copy). +- **Docstrings: 1–2 sentences of behaviour, honest about provenance** — spec-mandated, + SDK-defined, or pinning a known gap? Say which: provenance is the triage key when the test + later fails. A pinned-gap assertion breaking usually means a change *fixed* the gap; a + spec-mandated assertion breaking means a regression. +- Define things in dependency order; nothing forward-references. For client↔server tests: + handlers → server construction → client setup → act → assert — the test reads in the order + the conversation happens. +- Inline the server (or equivalent setup) in the test, so the whole observable behaviour fits + on one screen. Lift to a file-level fixture only when several tests in *that file* genuinely + share it; never share across files. +- A big multi-step test is fine when the property is irreducibly multi-step (e.g. resumability). + Split when a failure wouldn't tell you which claim broke — not for shortness. Compensate with + a numbered "Steps:" docstring so a reader sees the choreography before the body. + +## Level of abstraction + +- **Drive through the highest-level public API that can observe the property.** Hand-built wire + requests are brittle and don't prove the user-facing contract; tests that stay above the + internals keep working when the internals change. Drop to raw HTTP only when the assertion is + about something the high-level API *cannot* observe (status codes, headers, wire framing). +- **Scripting a peer over raw streams is a last resort**, reserved for behaviour the typed API + cannot *produce* (malformed input, an impossible peer response). First ask what it would take + for the public API to express it — often a small helper suffices. Every such test's docstring + states why the public API couldn't do it. +- **In-memory / in-process first.** HTTP-, SSE-, and auth-shaped behaviour can all be driven + through an in-process ASGI transport; threads only when necessary, subprocesses only when the + process boundary is itself the thing under test. In-process isn't just faster — it surfaces + bugs (a real stream leak was found this way) that subprocess indirection masks. +- **Tests read like real user code**: no aliasing shims in conftest, no walls of suppressions, + no private imports unless that is genuinely the documented way to do the thing. +- The assertion must prove the round trip — no side-channel state. What the server saw comes + back through the protocol, or via a closure-captured list asserted after the call. Handlers + assert their dispatch identity first (`assert params.name == "add"`), proving the request + that arrived is the request the test sent. + +## Assertions + +- **Transformations** (input → output the SDK produced) → full-object `snapshot(...)` equality, + so an added or dropped field fails. Regenerate with `--inline-snapshot=fix` so intentional + changes arrive as a reviewable diff; never hand-edit snapshot literals. +- **Pass-through values** (opaque tokens, `_meta`, cursors) → identity against the same + variable you sent. A snapshot of a pass-through value only "matches" because a human checked + two literals correspond — it proves nothing. +- **Errors** → `pytest.raises` + `.code` against the named constant; snapshot SDK-authored + messages; never `match=` on message text. Third-party text (pydantic, jsonschema) → stable + prefix only — never pin text that changes with a dependency upgrade — with a comment saying so. + +## Determinism + +- **No sleeps, ever.** A sleep guesses at timing instead of waiting on the condition, so it + either flakes or pads the run — sleeps head the list of the older test code's failure modes. + Coordinate with `anyio.Event` so the wait ends exactly when the condition holds (that + discipline is why 529 e2e tests run in ~10 s). Sole exception: tests *of* time-based + features, with a comment. +- Bound every indefinite wait with `anyio.fail_after(5)`. **5 is the standard** — widening it + needs an articulable reason; "10 to be safe" is covering up a flake, not fixing one, and + unexplained widenings propagate (one agent used 10 and every later one copied it). +- **Never assert wall-clock time, even with huge margins.** `elapsed < 0.9` on a ~0.01 s + operation — a 90× margin — was still rejected in review. `fail_after` bounding a hang is the + only timing primitive allowed. +- **Concurrency tests must prove genuine interleaving.** Without barriers the scheduler is free + to serialize — "a" can start, finish, and return before "b" even begins — so two `start_soon` + calls prove nothing. Gate with events so all parties are mid-flight before any proceeds, emit + interleaved, then assert the demux. +- **Don't over-synchronize either.** When delivery ordering is guaranteed (notifications + emitted during a request you're awaiting, over a single ordered in-memory stream), a plain + collected list asserted after the call is correct; events are for messages not tied to an + awaited operation. Verify the ordering guarantee actually holds for your transport first. +- Async tests use anyio, not asyncio. + +## Behaviour philosophy + +- **Pin current behaviour; never xfail.** A green suite asserting what actually happens is a + regression bar for any refactor; an xfail proves nothing about it. Where current behaviour + falls short of spec, pin the divergent output and record the gap as data — a tracking issue, + with the docstring naming the known gap (suites with a requirements manifest record it as a + divergence entry). Not hidden, not skipped. +- **Hollow-proof check**: before claiming a test covers a behaviour, re-read the claim and ask + "which assertion proves *this*?" A passing test near a behaviour is not proof of it — a full + review of the e2e suite found two such cases even under this discipline. + +## Hygiene + +- No new `# pragma`, `# type: ignore`, `# noqa` by default — restructure first; a suppression + usually means a test or a type is missing. The narrow sanctioned escape hatches (and the + audit to run before pushing) are in AGENTS.md. In tests, narrow types with `assert + isinstance`; never `Any`/`object` when a real type exists. +- **Warnings raised during tests are findings, not noise** (the repo runs + `filterwarnings = error`). Fix the cause; if the fix can't land in the same change, scope the + suppression to the one fixture that needs it and track the real fix explicitly. +- Registered-but-never-invoked handler bodies are `raise NotImplementedError`, so they cannot + silently become load-bearing. +- Comments live next to the line they explain, not in docstrings; single backticks for code + refs; match the surrounding comment density (one-liners next to one-liners). No + `from __future__ import annotations` (py310+ repo). +- Test work doesn't change `src/` as a side effect — the one mechanical exception is deleting a + pragma a new test now covers. If a test can't be written without a library change, raise it + as a finding or defer the test; don't quietly edit `src/`. + +## Process (for agent-driven work) + +- **Small chunks.** ≤10 tests per human-reviewed batch; when fanning out to multiple agents, + ≤5 per agent. Review quality scales inversely with batch size: the observed shortcuts + (over-stacked tests, a timing assertion, wrong abstraction level) all surfaced in one + oversized 27-test batch. +- **Design → review → implement, as separate steps.** The design deliverable is not just the + plan — it's the judgement calls (abstraction level, deferrals, contested assertions) stated + explicitly, so the reviewer vetoes them before implementation rather than discovering them + in the diff. +- **High-stakes areas get fresh adversarial reviewers** on both the design and the + implementation — fresh, because whoever designed it (or saw the proposed fix) anchors on the + same layer. SERIOUS findings (wrong assertion, missed MUST/SHOULD, an unrecorded known gap) + re-loop; style doesn't. Reserve the full panel for areas where being wrong is worse than + being slow. +- **Investigations pair a full-context look with a fresh agent given only the problem** — + never the proposed fix — and compare conclusions. The unbiased read regularly catches + anchoring on the wrong layer. +- **When review questions a decision, reconsider genuinely**: re-derive the tradeoff, state the + options, recommend with reasons. Defending the original choice is a valid outcome; + reflexively agreeing with every challenge is as bad as ignoring it. +- **Validate the reference artifact before building on it.** Whatever you treat as ground truth + (a spec import, a baseline, a generated list), check it *first* — discovering it was + incomplete after five batches costs far more than before batch one. +- **Verify as you go**: per-file `uv run --frozen pytest <file> -q` + pyright + ruff while + iterating; full suite, coverage, and `./scripts/test` (when `src/` was touched) at + integration. +- **Every agent writes notes**: what it did, what broke, and — most importantly — what it + couldn't decide, so open questions surface instead of being silently resolved by whoever hit + them. +- **Quality over speed is a stated goal, not a preference** ("rushing something out the door is + not the goal here, explicitly so"). Don't quietly de-scope agreed work mid-stream; + renegotiate scope explicitly. +- **Don't copy patterns from existing test code by default** — much of the repo's older test + code is below the current bar (sleeps, mocks, `Test` classes, raciness). These rules define + the bar for new work. diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..9fd6c03 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,5 @@ +# Applied 120 line-length rule to all files: https://github.com/modelcontextprotocol/python-sdk/pull/856 +543961968c0634e93d919d509cce23a1d6a56c21 + +# Added 100% code coverage baseline with pragma comments: https://github.com/modelcontextprotocol/python-sdk/pull/1553 +89e9c43acf7e23cf766357d776ec1ce63ac2c58e diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0ab3744 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Generated +uv.lock linguist-generated=true diff --git a/.github/ISSUE_TEMPLATE/bug.yaml b/.github/ISSUE_TEMPLATE/bug.yaml new file mode 100644 index 0000000..617ff98 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yaml @@ -0,0 +1,55 @@ +name: 🐛 MCP Python SDK Bug +description: Report a bug or unexpected behavior in the MCP Python SDK +labels: ["need confirmation"] + +body: + - type: markdown + attributes: + value: Thank you for contributing to the MCP Python SDK! ✊ + + - type: checkboxes + id: checks + attributes: + label: Initial Checks + description: Just making sure you're using the latest version of MCP Python SDK. + options: + - label: I confirm that I'm using the latest version of MCP Python SDK + required: true + - label: I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue + required: true + + - type: textarea + id: description + attributes: + label: Description + description: | + Please explain what you're seeing and what you would expect to see. + + Please provide as much detail as possible to make understanding and solving your problem as quick as possible. 🙏 + validations: + required: true + + - type: textarea + id: example + attributes: + label: Example Code + description: > + If applicable, please add a self-contained, + [minimal, reproducible, example](https://stackoverflow.com/help/minimal-reproducible-example) + demonstrating the bug. + + placeholder: | + from mcp.server.mcpserver import MCPServer + + ... + render: Python + + - type: textarea + id: version + attributes: + label: Python & MCP Python SDK + description: | + Which version of Python and MCP Python SDK are you using? + render: Text + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yaml b/.github/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature-request.yaml b/.github/ISSUE_TEMPLATE/feature-request.yaml new file mode 100644 index 0000000..bec9b77 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yaml @@ -0,0 +1,29 @@ +name: 🚀 MCP Python SDK Feature Request +description: "Suggest a new feature for the MCP Python SDK" +labels: ["feature request"] + +body: + - type: markdown + attributes: + value: Thank you for contributing to the MCP Python SDK! ✊ + + - type: textarea + id: description + attributes: + label: Description + description: | + Please give as much detail as possible about the feature you would like to suggest. 🙏 + + You might like to add: + * A demo of how code might look when using the feature + * Your use case(s) for the feature + * Reference to other projects that have a similar feature + validations: + required: true + + - type: textarea + id: references + attributes: + label: References + description: | + Please add any links or references that might help us understand your feature request better. 📚 diff --git a/.github/ISSUE_TEMPLATE/question.yaml b/.github/ISSUE_TEMPLATE/question.yaml new file mode 100644 index 0000000..87a7894 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yaml @@ -0,0 +1,33 @@ +name: ❓ MCP Python SDK Question +description: "Ask a question about the MCP Python SDK" +labels: ["question"] + +body: + - type: markdown + attributes: + value: Thank you for reaching out to the MCP Python SDK community! We're here to help! 🤝 + + - type: textarea + id: question + attributes: + label: Question + description: | + Please provide as much detail as possible about your question. 🙏 + + You might like to include: + * Code snippets showing what you've tried + * Error messages you're encountering (if any) + * Expected vs actual behavior + * Your use case and what you're trying to achieve + validations: + required: true + + - type: textarea + id: context + attributes: + label: Additional Context + description: | + Please provide any additional context that might help us better understand your question, such as: + * Your MCP Python SDK version + * Your Python version + * Relevant configuration or environment details 📝 diff --git a/.github/ISSUE_TEMPLATE/v2-feedback.yaml b/.github/ISSUE_TEMPLATE/v2-feedback.yaml new file mode 100644 index 0000000..35ed633 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/v2-feedback.yaml @@ -0,0 +1,59 @@ +name: v2 feedback +description: Bugs, API friction, or docs gaps in v2 of the SDK +title: "[v2] " +labels: ["v2-alpha"] + +body: + - type: markdown + attributes: + value: | + Thanks for trying v2. Anything that broke, surprised you, or slowed you down is useful — API feedback is explicitly welcome while v2 is in pre-release. + + Docs: https://py.sdk.modelcontextprotocol.io/v2/ · Migration from v1: https://py.sdk.modelcontextprotocol.io/v2/migration/ + + - type: textarea + id: what + attributes: + label: What happened? + description: What did you do, and what went wrong (or felt wrong)? Paste error output verbatim if there is any. + validations: + required: true + + - type: textarea + id: expected + attributes: + label: What did you expect? + validations: + required: false + + - type: textarea + id: repro + attributes: + label: Code to reproduce + description: The smallest snippet or repository that shows it. For docs feedback, link the page instead. + render: Python + validations: + required: false + + - type: input + id: version + attributes: + label: SDK version + description: The published version (`pip show mcp`) or commit. + validations: + required: false + + - type: dropdown + id: area + attributes: + label: Area + options: + - Server + - Client + - Transports + - Auth + - Documentation + - Migration + - Other + validations: + required: false diff --git a/.github/actions/conformance/client.py b/.github/actions/conformance/client.py new file mode 100644 index 0000000..39150ad --- /dev/null +++ b/.github/actions/conformance/client.py @@ -0,0 +1,614 @@ +"""MCP unified conformance test client. + +This client is designed to work with the @modelcontextprotocol/conformance npm package. +It handles all conformance test scenarios via environment variables and CLI arguments. + +Contract: + - MCP_CONFORMANCE_SCENARIO env var -> scenario name + - MCP_CONFORMANCE_CONTEXT env var -> optional JSON (for client-credentials scenarios) + - MCP_CONFORMANCE_PROTOCOL_VERSION env var -> spec version the harness mock + server is speaking (e.g. "2025-11-25", "2026-07-28"). Always set; when + --spec-version is omitted the harness picks per-scenario (LATEST_SPEC_VERSION + for active scenarios, DRAFT_PROTOCOL_VERSION for draft-only ones). + - Server URL as last CLI argument (sys.argv[1]) + - Must exit 0 within 30 seconds + +Scenarios: + initialize - Connect, initialize, list tools, close + tools_call - Connect, call add_numbers(a=5, b=3), close + sse-retry - Connect, call test_reconnection, close + json-schema-ref-no-deref - Connect, list tools (no $ref deref) + request-metadata - Connect with all callbacks; client stamps _meta + http-standard-headers - Connect, call a tool (Mcp-* headers checked) + http-invalid-tool-headers - List tools, call every surfaced tool (x-mcp-header filter) + elicitation-sep1034-client-defaults - Elicitation with default accept callback + sep-2322-client-request-state - Drive the MRTR auto-loop (SEP-2322) + auth/client-credentials-jwt - Client credentials with private_key_jwt + auth/client-credentials-basic - Client credentials with client_secret_basic + auth/enterprise-managed-authorization - SEP-990 ID-JAG (RFC 8693 + RFC 7523 jwt-bearer) + auth/* - Authorization code flow (default for auth scenarios) +""" + +import asyncio +import json +import logging +import os +import sys +from collections.abc import Callable, Coroutine +from typing import Any, cast +from urllib.parse import parse_qs, urlparse + +import httpx +import mcp_types as types +from mcp_types.version import MODERN_PROTOCOL_VERSIONS +from pydantic import AnyUrl + +from mcp.client.auth import OAuthClientProvider, TokenStorage +from mcp.client.auth.extensions.client_credentials import ( + ClientCredentialsOAuthProvider, + PrivateKeyJWTOAuthProvider, + SignedJWTParameters, +) +from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider +from mcp.client.auth.utils import build_protected_resource_metadata_discovery_urls +from mcp.client.client import Client +from mcp.client.context import ClientRequestContext +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata, OAuthToken + +# Set up logging to stderr (stdout is for conformance test output) +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + stream=sys.stderr, +) +logger = logging.getLogger(__name__) + +#: Spec version the harness is running this scenario at (e.g. "2025-11-25", +#: "2026-07-28"). The harness always sets this (when --spec-version is omitted +#: it picks per-scenario: LATEST_SPEC_VERSION for active scenarios, +#: DRAFT_PROTOCOL_VERSION for draft-only ones), so None means we were invoked +#: outside the harness. +PROTOCOL_VERSION: str | None = os.environ.get("MCP_CONFORMANCE_PROTOCOL_VERSION") + + +def client_mode() -> str: + """Pick the Client(mode=) for the harness leg. + + On a modern leg (2026-07-28+) -> 'auto' so Client.discover() runs and the + _meta envelope + MCP-Protocol-Version header are stamped on every request. + On a handshake-era leg -> 'legacy' so the initialize handshake runs exactly + as before (no server/discover probe is sent against a mock that would 400 it). + Outside the harness -> 'auto' (probe + fallback). + """ + if PROTOCOL_VERSION is None or PROTOCOL_VERSION in MODERN_PROTOCOL_VERSIONS: + return "auto" + return "legacy" + + +# Type for async scenario handler functions +ScenarioHandler = Callable[[str], Coroutine[Any, None, None]] + +# Registry of scenario handlers +HANDLERS: dict[str, ScenarioHandler] = {} + + +def register(name: str) -> Callable[[ScenarioHandler], ScenarioHandler]: + """Register a scenario handler.""" + + def decorator(fn: ScenarioHandler) -> ScenarioHandler: + HANDLERS[name] = fn + return fn + + return decorator + + +def get_conformance_context() -> dict[str, Any]: + """Load conformance test context from MCP_CONFORMANCE_CONTEXT environment variable.""" + context_json = os.environ.get("MCP_CONFORMANCE_CONTEXT") + if not context_json: + raise RuntimeError( + "MCP_CONFORMANCE_CONTEXT environment variable not set. " + "Expected JSON with client_id, client_secret, and/or private_key_pem." + ) + try: + return json.loads(context_json) + except json.JSONDecodeError as e: + raise RuntimeError(f"Failed to parse MCP_CONFORMANCE_CONTEXT as JSON: {e}") from e + + +class InMemoryTokenStorage(TokenStorage): + """Simple in-memory token storage for conformance testing.""" + + def __init__(self) -> None: + self._tokens: OAuthToken | None = None + self._client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self._tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + self._tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self._client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self._client_info = client_info + + +class ConformanceOAuthCallbackHandler: + """OAuth callback handler that automatically fetches the authorization URL + and extracts the auth code, without requiring user interaction. + """ + + def __init__(self) -> None: + self._auth_code: str | None = None + self._state: str | None = None + self._iss: str | None = None + + async def handle_redirect(self, authorization_url: str) -> None: + """Fetch the authorization URL and extract the auth code from the redirect.""" + logger.debug(f"Fetching authorization URL: {authorization_url}") + + async with httpx.AsyncClient() as client: + response = await client.get( + authorization_url, + follow_redirects=False, + ) + + if response.status_code in (301, 302, 303, 307, 308): + location = cast(str, response.headers.get("location")) + if location: + redirect_url = urlparse(location) + query_params: dict[str, list[str]] = parse_qs(redirect_url.query) + + if "code" in query_params: + self._auth_code = query_params["code"][0] + state_values = query_params.get("state") + self._state = state_values[0] if state_values else None + iss_values = query_params.get("iss") + self._iss = iss_values[0] if iss_values else None + logger.debug(f"Got auth code from redirect: {self._auth_code[:10]}...") + return + else: + raise RuntimeError(f"No auth code in redirect URL: {location}") + else: + raise RuntimeError(f"No redirect location received from {authorization_url}") + else: + raise RuntimeError(f"Expected redirect response, got {response.status_code} from {authorization_url}") + + async def handle_callback(self) -> AuthorizationCodeResult: + """Return the captured auth code, state, and iss.""" + if self._auth_code is None: + raise RuntimeError("No authorization code available - was handle_redirect called?") + result = AuthorizationCodeResult(code=self._auth_code, state=self._state, iss=self._iss) + self._auth_code = None + self._state = None + self._iss = None + return result + + +# --- Stub callbacks (declare capabilities in _meta without doing real work) --- + + +async def stub_sampling_callback( + context: ClientRequestContext, + params: types.CreateMessageRequestParams, +) -> types.CreateMessageResult | types.ErrorData: + return types.CreateMessageResult( + role="assistant", + content=types.TextContent(type="text", text=""), + model="conformance-stub", + ) + + +async def stub_list_roots_callback(context: ClientRequestContext) -> types.ListRootsResult | types.ErrorData: + return types.ListRootsResult(roots=[]) + + +async def default_elicitation_callback( + context: ClientRequestContext, + params: types.ElicitRequestParams, +) -> types.ElicitResult | types.ErrorData: + """Accept elicitation and apply defaults from the schema (SEP-1034).""" + content: dict[str, str | int | float | bool | list[str] | None] = {} + + # For form mode, extract defaults from the requested_schema + if isinstance(params, types.ElicitRequestFormParams): + schema = params.requested_schema + logger.debug(f"Elicitation schema: {schema}") + properties = schema.get("properties", {}) + for prop_name, prop_schema in properties.items(): + if "default" in prop_schema: + content[prop_name] = prop_schema["default"] + logger.debug(f"Applied defaults: {content}") + + return types.ElicitResult(action="accept", content=content) + + +# --- Scenario Handlers --- + + +@register("initialize") +async def run_initialize(server_url: str) -> None: + """Connect, initialize, list tools, close.""" + async with Client(server_url, mode=client_mode()) as client: + logger.debug("Initialized successfully") + await client.list_tools() + logger.debug("Listed tools successfully") + + +@register("json-schema-ref-no-deref") +async def run_json_schema_ref_no_deref(server_url: str) -> None: + """Initialize and list tools; the scenario fails only if the client fetches a network $ref. + + The client never walks inputSchema or resolves $refs, so listing is enough (SEP-2106). + Pinned to mode='legacy': the harness reports PROTOCOL_VERSION=2026-07-28 for this + scenario but its mock server only speaks the handshake-era lifecycle and 400s a + modern-stamped tools/list. The check is lifecycle-agnostic so this is harmless. + """ + async with Client(server_url, mode="legacy") as client: + await client.list_tools() + + +@register("tools_call") +async def run_tools_call(server_url: str) -> None: + """Connect, list tools, call add_numbers(a=5, b=3), close.""" + async with Client(server_url, mode=client_mode()) as client: + await client.list_tools() + result = await client.call_tool("add_numbers", {"a": 5, "b": 3}) + logger.debug(f"add_numbers result: {result}") + + +@register("sse-retry") +async def run_sse_retry(server_url: str) -> None: + """Connect, list tools, call test_reconnection, close.""" + async with Client(server_url, mode=client_mode()) as client: + await client.list_tools() + result = await client.call_tool("test_reconnection", {}) + logger.debug(f"test_reconnection result: {result}") + + +@register("request-metadata") +async def run_request_metadata(server_url: str) -> None: + """Connect on the modern path with every client capability declared. + + The scenario inspects every request's `_meta` envelope (SEP-2575) for + protocolVersion / clientInfo / clientCapabilities, and the matching + MCP-Protocol-Version header. mode='auto' makes the SDK send + server/discover (covering the unsupported-version retry check), then adopt + and stamp the envelope on the follow-up requests. + """ + async with Client( + server_url, + mode=client_mode(), + sampling_callback=stub_sampling_callback, + list_roots_callback=stub_list_roots_callback, + elicitation_callback=default_elicitation_callback, + ) as client: + await client.list_tools() + result = await client.call_tool("add_numbers", {"a": 5, "b": 3}) + logger.debug(f"add_numbers result: {result}") + + +@register("http-standard-headers") +async def run_http_standard_headers(server_url: str) -> None: + """Connect on the modern path so Mcp-Method / Mcp-Name / MCP-Protocol-Version are sent (SEP-2243).""" + async with Client(server_url, mode=client_mode()) as client: + await client.list_tools() + result = await client.call_tool("add_numbers", {"a": 5, "b": 3}) + logger.debug(f"add_numbers result: {result}") + + +def _stub_required_args(input_schema: dict[str, Any]) -> dict[str, Any]: + """Minimal arguments satisfying a tool inputSchema's required list.""" + by_type: dict[str, Any] = { + "string": "x", + "integer": 0, + "number": 0, + "boolean": False, + "object": {}, + "array": [], + "null": None, + } + properties = input_schema.get("properties", {}) + return {name: by_type.get(properties.get(name, {}).get("type"), "x") for name in input_schema.get("required", [])} + + +@register("http-invalid-tool-headers") +async def run_http_invalid_tool_headers(server_url: str) -> None: + """List tools, then call every tool the SDK surfaces (SEP-2243). + + The harness mock advertises one valid tool plus several with malformed + x-mcp-header annotations (empty, non-primitive type, duplicate, invalid + chars). The scenario passes if valid_tool is called and the malformed + ones are not -- so a conforming client filters them out of the list_tools + result and the loop below never sees them. The scenario sets + allowClientError, so a per-call failure is logged and skipped rather + than aborting the whole run. + """ + async with Client(server_url, mode=client_mode()) as client: + listed = await client.list_tools() + logger.debug(f"Surfaced tools: {[t.name for t in listed.tools]}") + for tool in listed.tools: + try: + await client.call_tool(tool.name, _stub_required_args(tool.input_schema)) + except Exception: + logger.exception(f"call_tool({tool.name!r}) failed") + + +@register("http-custom-headers") +async def run_http_custom_headers(server_url: str) -> None: + """List tools, then replay the harness's `toolCalls` so x-mcp-header args mirror into headers (SEP-2243). + + The scenario supplies the exact arguments to send (including the null/edge-case values that + exercise omission and Base64 encoding) via the context `toolCalls`; using them verbatim is + what drives every per-parameter check. `list_tools` first so the SDK caches each tool's + annotations; a tool the SDK dropped (invalid annotations) is skipped. Per-call failures are + logged and skipped rather than aborting the run. + """ + tool_calls: list[dict[str, Any]] = [] + if os.environ.get("MCP_CONFORMANCE_CONTEXT"): + tool_calls = get_conformance_context().get("toolCalls", []) + async with Client(server_url, mode=client_mode()) as client: + listed = await client.list_tools() + surfaced = {tool.name for tool in listed.tools} + logger.debug(f"Surfaced tools: {sorted(surfaced)}") + for call in tool_calls: + name = call["name"] + if name not in surfaced: + logger.debug(f"skipping {name!r}: not surfaced by list_tools") + continue + try: + await client.call_tool(name, call.get("arguments") or {}) + except Exception: + logger.exception(f"call_tool({name!r}) failed") + + +@register("elicitation-sep1034-client-defaults") +async def run_elicitation_defaults(server_url: str) -> None: + """Connect with elicitation callback that applies schema defaults.""" + async with Client(server_url, mode=client_mode(), elicitation_callback=default_elicitation_callback) as client: + await client.list_tools() + result = await client.call_tool("test_client_elicitation_defaults", {}) + logger.debug(f"test_client_elicitation_defaults result: {result}") + + +@register("sep-2322-client-request-state") +async def run_mrtr_client(server_url: str) -> None: + """Drive the SEP-2322 client mock through `Client.call_tool`'s auto-loop. + + The mock inspects raw `tools/call` params, so registering an + `elicitation_callback` and letting the driver run is enough to satisfy + all five wire-shape checks: the driver echoes `request_state` byte-exact + and omits it when the server sent none, every retry mints a fresh + JSON-RPC id, the unrelated call between auto-loops carries no MRTR + params, and the no-`resultType` response parses as a terminal + `CallToolResult` so the driver never retries it. + """ + + async def confirm( + context: ClientRequestContext, params: types.ElicitRequestParams + ) -> types.ElicitResult | types.ErrorData: + return types.ElicitResult(action="accept", content={"confirmed": True}) + + async with Client(server_url, mode=client_mode(), elicitation_callback=confirm) as client: + await client.list_tools() + + await client.call_tool("test_mrtr_echo_state", {}) + await client.call_tool("test_mrtr_unrelated", {}) + await client.call_tool("test_mrtr_no_state", {}) + + result = await client.call_tool("test_mrtr_no_result_type", {}) + assert isinstance(result, types.CallToolResult) + + +@register("auth/client-credentials-jwt") +async def run_client_credentials_jwt(server_url: str) -> None: + """Client credentials flow with private_key_jwt authentication.""" + context = get_conformance_context() + client_id = context.get("client_id") + private_key_pem = context.get("private_key_pem") + signing_algorithm = context.get("signing_algorithm", "ES256") + + if not client_id: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'") + if not private_key_pem: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'private_key_pem'") + + jwt_params = SignedJWTParameters( + issuer=client_id, + subject=client_id, + signing_algorithm=signing_algorithm, + signing_key=private_key_pem, + ) + + oauth_auth = PrivateKeyJWTOAuthProvider( + server_url=server_url, + storage=InMemoryTokenStorage(), + client_id=client_id, + assertion_provider=jwt_params.create_assertion_provider(), + ) + + await _run_auth_session(server_url, oauth_auth) + + +@register("auth/client-credentials-basic") +async def run_client_credentials_basic(server_url: str) -> None: + """Client credentials flow with client_secret_basic authentication.""" + context = get_conformance_context() + client_id = context.get("client_id") + client_secret = context.get("client_secret") + + if not client_id: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'") + if not client_secret: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_secret'") + + oauth_auth = ClientCredentialsOAuthProvider( + server_url=server_url, + storage=InMemoryTokenStorage(), + client_id=client_id, + client_secret=client_secret, + token_endpoint_auth_method="client_secret_basic", + ) + + await _run_auth_session(server_url, oauth_auth) + + +@register("auth/enterprise-managed-authorization") +async def run_enterprise_managed_authorization(server_url: str) -> None: + """SEP-990 enterprise-managed authorization: RFC 8693 token-exchange at the + enterprise IdP for an ID-JAG, then RFC 7523 jwt-bearer at the MCP + authorization server.""" + context = get_conformance_context() + client_id = context.get("client_id") + client_secret = context.get("client_secret") + idp_client_id = context.get("idp_client_id") + idp_id_token = context.get("idp_id_token") + idp_token_endpoint = context.get("idp_token_endpoint") + + if not client_id: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'") + if not client_secret: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_secret'") + if not idp_client_id: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_client_id'") + if not idp_id_token: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_id_token'") + if not idp_token_endpoint: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_token_endpoint'") + + # IdentityAssertionOAuthProvider takes the AS issuer as configuration (the + # SEP-990 trust model: the resource server is never asked which AS to use). + # The harness does not put the issuer in context, so for conformance we + # learn it from the harness's PRM document (RFC 9728); production + # deployments would supply it as static configuration instead. + prm_url = build_protected_resource_metadata_discovery_urls(None, server_url)[0] + async with httpx.AsyncClient(timeout=30.0) as http: + prm = (await http.get(prm_url)).raise_for_status().json() + as_issuer = prm["authorization_servers"][0] + + async def fetch_id_jag(audience: str, resource: str) -> str: + """Leg 1 - RFC 8693 token-exchange at the enterprise IdP.""" + async with httpx.AsyncClient(timeout=30.0) as http: + resp = await http.post( + idp_token_endpoint, + data={ + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "requested_token_type": "urn:ietf:params:oauth:token-type:id-jag", + "subject_token": idp_id_token, + "subject_token_type": "urn:ietf:params:oauth:token-type:id_token", + "audience": audience, + "resource": resource, + "client_id": idp_client_id, + }, + ) + resp.raise_for_status() + return resp.json()["access_token"] + + oauth_auth = IdentityAssertionOAuthProvider( + server_url=server_url, + storage=InMemoryTokenStorage(), + client_id=client_id, + client_secret=client_secret, + issuer=as_issuer, + assertion_provider=fetch_id_jag, + token_endpoint_auth_method="client_secret_basic", + ) + + await _run_auth_session(server_url, oauth_auth) + + +async def run_auth_code_client(server_url: str) -> None: + """Authorization code flow (default for auth/* scenarios).""" + callback_handler = ConformanceOAuthCallbackHandler() + storage = InMemoryTokenStorage() + + # Check for pre-registered client credentials from context + context_json = os.environ.get("MCP_CONFORMANCE_CONTEXT") + if context_json: + try: + context = json.loads(context_json) + client_id = context.get("client_id") + client_secret = context.get("client_secret") + if client_id: + await storage.set_client_info( + OAuthClientInformationFull( + client_id=client_id, + client_secret=client_secret, + redirect_uris=[AnyUrl("http://localhost:3000/callback")], + token_endpoint_auth_method="client_secret_basic" if client_secret else "none", + ) + ) + logger.debug(f"Pre-loaded client credentials: client_id={client_id}") + except json.JSONDecodeError: + logger.exception("Failed to parse MCP_CONFORMANCE_CONTEXT") + + oauth_auth = OAuthClientProvider( + server_url=server_url, + client_metadata=OAuthClientMetadata( + client_name="conformance-client", + redirect_uris=[AnyUrl("http://localhost:3000/callback")], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + ), + storage=storage, + redirect_handler=callback_handler.handle_redirect, + callback_handler=callback_handler.handle_callback, + client_metadata_url="https://conformance-test.local/client-metadata.json", + ) + + await _run_auth_session(server_url, oauth_auth) + + +async def _run_auth_session(server_url: str, oauth_auth: httpx.Auth) -> None: + """Common session logic for all OAuth flows.""" + http_client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0) + transport = streamable_http_client(url=server_url, http_client=http_client) + async with Client(transport, mode=client_mode(), elicitation_callback=default_elicitation_callback) as client: + logger.debug("Initialized successfully") + + tools_result = await client.list_tools() + logger.debug(f"Listed tools: {[t.name for t in tools_result.tools]}") + + # Call the first available tool (different tests have different tools) + if tools_result.tools: + tool_name = tools_result.tools[0].name + try: + result = await client.call_tool(tool_name, {}) + logger.debug(f"Called {tool_name}, result: {result}") + except Exception as e: + logger.debug(f"Tool call result/error: {e}") + + logger.debug("Connection closed successfully") + + +def main() -> None: + """Main entry point for the conformance client.""" + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} <server-url>", file=sys.stderr) + sys.exit(1) + + server_url = sys.argv[1] + scenario = os.environ.get("MCP_CONFORMANCE_SCENARIO") + logger.debug(f"Conformance protocol version: {PROTOCOL_VERSION!r} -> mode={client_mode()!r}") + + if scenario: + logger.debug(f"Running explicit scenario '{scenario}' against {server_url}") + handler = HANDLERS.get(scenario) + if handler: + asyncio.run(handler(server_url)) + elif scenario.startswith("auth/"): + asyncio.run(run_auth_code_client(server_url)) + else: + print(f"Unknown scenario: {scenario}", file=sys.stderr) + sys.exit(1) + else: + logger.debug(f"Running default auth flow against {server_url}") + asyncio.run(run_auth_code_client(server_url)) + + +if __name__ == "__main__": + main() diff --git a/.github/actions/conformance/expected-failures.2026-07-28.yml b/.github/actions/conformance/expected-failures.2026-07-28.yml new file mode 100644 index 0000000..504b463 --- /dev/null +++ b/.github/actions/conformance/expected-failures.2026-07-28.yml @@ -0,0 +1,25 @@ +# Expected failures for the carried-forward 2026-07-28 legs +# (`--suite all --spec-version 2026-07-28` for both server and client). +# +# This baseline is separate from expected-failures.yml because entries are +# keyed by scenario name only: a scenario that passes at its default version +# in the 2025 legs but fails when forced to 2026-07-28 (or vice versa) cannot +# be expressed in a shared file (the passing leg would flag the entry as +# stale). Like expected-failures.yml, this single file covers both +# directions: the client 2026 leg reads the `client:` section and the server +# 2026 leg reads the `server:` section. Both burn down independently of the +# 2025 legs. +# +# Baseline established against the harness pinned via CONFORMANCE_PKG in +# .github/workflows/conformance.yml. New conformance releases are adopted by +# deliberately bumping that pin and reconciling both this file and +# expected-failures.yml in the same change. +# +# Entries are grouped by what unblocks them. As each gap closes the +# corresponding scenarios start passing and MUST be removed from this list +# (the runner fails on stale entries), so the baseline burns down per +# milestone. + +client: [] + +server: [] diff --git a/.github/actions/conformance/expected-failures.yml b/.github/actions/conformance/expected-failures.yml new file mode 100644 index 0000000..aa31cb7 --- /dev/null +++ b/.github/actions/conformance/expected-failures.yml @@ -0,0 +1,34 @@ +# Conformance scenarios not yet passing against the Python SDK on main. +# CI exits 0 if only these fail, exits 1 on unexpected failures or stale entries. +# +# Baseline established against the harness pinned via CONFORMANCE_PKG in +# .github/workflows/conformance.yml. New conformance releases are adopted by +# deliberately bumping that pin and reconciling both this file and +# expected-failures.2026-07-28.yml in the same change. +# +# Entries are grouped by SEP. As each SEP lands in the SDK the corresponding +# scenarios start passing and MUST be removed from this list (the runner fails +# on stale entries), so the baseline burns down per milestone. + +client: [] + +server: + # SEP-2663 (io.modelcontextprotocol/tasks): the SDK does not implement the + # tasks extension yet. These extension-tagged scenarios are selected only by + # the bare `--suite all` leg — extension scenarios never match a + # --spec-version filter and the active/draft suites exclude them — so these + # entries are inert for the other legs that read this file. + # + # `tasks-status-notifications` is intentionally NOT listed: the harness + # skips it unconditionally (pending its rewrite against subscriptions/ + # listen), and a baseline entry for a scenario with no failing checks is + # flagged stale. + - tasks-lifecycle + - tasks-capability-negotiation + - tasks-wire-fields + - tasks-request-state-removal + - tasks-mrtr-input + - tasks-request-headers + - tasks-dispatch-and-envelope + - tasks-required-task-error + - tasks-mrtr-composition diff --git a/.github/actions/conformance/run-client.sh b/.github/actions/conformance/run-client.sh new file mode 100755 index 0000000..3c96788 --- /dev/null +++ b/.github/actions/conformance/run-client.sh @@ -0,0 +1,104 @@ +#!/bin/bash +# Run a client conformance suite, re-verifying unexpected failures solo. +# Concurrent suite runs on a 2-vCPU runner can push scenarios with real-time +# waits past tolerance; solo, a real failure fails again while a contention +# artifact passes. Failures that only reproduce under concurrency are excused. +set -uo pipefail + +: "${CONFORMANCE_PKG:?set CONFORMANCE_PKG (pinned in .github/workflows/conformance.yml)}" +# One attempt: a solo failure on the quiet runner disproves the contention +# hypothesis; a second try would be the blind retry this script avoids. +SOLO_ATTEMPTS="${CONFORMANCE_SOLO_ATTEMPTS:-1}" + +# Relative args resolve from the repo root; same contract as run-server.sh. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/../../.." || exit 1 + +log="$(mktemp)" +trap 'rm -f "$log"' EXIT + +npx --yes "$CONFORMANCE_PKG" client "$@" 2>&1 | tee "$log" +rc=${PIPESTATUS[0]} +if [ "$rc" -eq 0 ]; then + exit 0 +fi + +plain="$(sed 's/\x1b\[[0-9;]*m//g' "$log")" + +# If the harness's summary wording changes, the list comes up empty and the +# original exit code passes through - never a false green. +mapfile -t scenarios < <( + printf '%s\n' "$plain" | + sed -n '/^Unexpected failures (not in baseline):$/,/^$/p' | + sed -n 's/^ ✗ //p' +) +if [ "${#scenarios[@]}" -eq 0 ]; then + exit "$rc" +fi +for scenario in "${scenarios[@]}"; do + if ! [[ "$scenario" =~ ^[A-Za-z0-9/_-]+$ ]]; then + echo "Extracted unexpected-failure name '${scenario}' does not look like a scenario name; passing the suite failure through." >&2 + exit "$rc" + fi +done + +# A stale baseline entry is a configuration error a solo rerun cannot excuse. +# Here-string, not a pipe: grep -q quitting early would SIGPIPE printf and, +# under pipefail, skip this guard exactly when the pattern is present. +if grep -q '^Stale baseline entries' <<<"$plain"; then + echo "Suite also reported stale baseline entries; not retrying." >&2 + exit "$rc" +fi + +# Drop the suite-only flags: --scenario replaces --suite, and solo runs are +# judged directly rather than against the baseline. +rerun_args=() +output_dir="" +skip_next=0 +expect_output_dir=0 +for arg in "$@"; do + if [ "$skip_next" -eq 1 ]; then + if [ "$expect_output_dir" -eq 1 ]; then + output_dir="$arg" + fi + skip_next=0 + expect_output_dir=0 + continue + fi + case "$arg" in + --output-dir) + skip_next=1 + expect_output_dir=1 + ;; + --suite | --expected-failures) skip_next=1 ;; + --output-dir=*) output_dir="${arg#--output-dir=}" ;; + --suite=* | --expected-failures=*) ;; + *) rerun_args+=("$arg") ;; + esac +done +if [ -n "$output_dir" ]; then + rerun_args+=(--output-dir "${output_dir}-solo") +fi + +for scenario in "${scenarios[@]}"; do + passed=0 + for attempt in $(seq 1 "$SOLO_ATTEMPTS"); do + echo "" + echo "Re-running '${scenario}' solo (attempt ${attempt}/${SOLO_ATTEMPTS})..." + if npx --yes "$CONFORMANCE_PKG" client --scenario "$scenario" "${rerun_args[@]}"; then + passed=1 + break + fi + done + if [ "$passed" -ne 1 ]; then + echo "'${scenario}' still fails when run alone: real failure, not suite contention." >&2 + exit 1 + fi +done + +if [ -n "$output_dir" ]; then + mkdir -p "$output_dir" + printf '%s\n' "${scenarios[@]}" > "$output_dir/FLAKE_RESCUED" +fi +echo "All ${#scenarios[@]} unexpected failure(s) passed when re-run solo; the suite failures were parallel-run contention." +exit 0 diff --git a/.github/actions/conformance/run-server.sh b/.github/actions/conformance/run-server.sh new file mode 100755 index 0000000..c026f4a --- /dev/null +++ b/.github/actions/conformance/run-server.sh @@ -0,0 +1,51 @@ +#!/bin/bash +set -e + +PORT="${PORT:-3001}" +SERVER_URL="http://localhost:${PORT}/mcp" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/../../.." + +# Refuse to start if something is already listening on the port. The readiness +# check below cannot tell our server apart from a stale one, so a leftover +# listener would mean silently running conformance against old code. +if (: > "/dev/tcp/localhost/${PORT}") 2>/dev/null; then + echo "Error: port ${PORT} is already in use." >&2 + echo "Stop the stale process first (lsof -ti:${PORT} -sTCP:LISTEN | xargs kill) or set PORT to a free port." >&2 + exit 1 +fi + +echo "Starting mcp-everything-server on port ${PORT}..." +uv run --frozen mcp-everything-server --port "$PORT" & +SERVER_PID=$! + +cleanup() { + echo "Stopping server (PID: ${SERVER_PID})..." + kill $SERVER_PID 2>/dev/null || true + wait $SERVER_PID 2>/dev/null || true +} +trap cleanup EXIT + +# Wait for server to be ready. --max-time keeps a hung listener from wedging +# the loop, and a dead server process fails fast instead of retrying. +echo "Waiting for server to be ready..." +MAX_RETRIES=30 +RETRY_COUNT=0 +while ! curl -s --max-time 2 "$SERVER_URL" > /dev/null 2>&1; do + if ! kill -0 $SERVER_PID 2>/dev/null; then + echo "Server process exited unexpectedly" >&2 + exit 1 + fi + RETRY_COUNT=$((RETRY_COUNT + 1)) + if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then + echo "Server failed to start after ${MAX_RETRIES} retries" >&2 + exit 1 + fi + sleep 0.5 +done + +echo "Server ready at $SERVER_URL" + +npx --yes "${CONFORMANCE_PKG:?set CONFORMANCE_PKG (pinned in .github/workflows/conformance.yml)}" \ + server --url "$SERVER_URL" "$@" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ffe967e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: + - package-ecosystem: "uv" + directory: "/" + schedule: + interval: monthly + cooldown: + default-days: 14 + groups: + python-packages: + patterns: + - "*" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: monthly + cooldown: + default-days: 14 + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 0000000..859055d --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,42 @@ +# Source: https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude') && !startsWith(github.event.comment.body, '@claude review')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@d5726de019ec4498aa667642bc3a80fca83aa102 # v1.0.148 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # zizmor: ignore[secrets-outside-env] + use_commit_signing: true + additional_permissions: | + actions: read diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml new file mode 100644 index 0000000..dd13269 --- /dev/null +++ b/.github/workflows/conformance.yml @@ -0,0 +1,168 @@ +name: Conformance Tests + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: conformance-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + # Pinned conformance harness package spec (passed verbatim to `npx --yes`). + # Use a published version, e.g. @modelcontextprotocol/conformance@0.2.0-alpha.7. + # Bump deliberately and reconcile both + # .github/actions/conformance/expected-failures*.yml files in the same change. + # + # Temporarily pinned to the pkg.pr.new build of conformance main@4944b268 + # (0.2.0-alpha.8, which includes #372: fail checks whose prerequisite is + # missing instead of skipping them) — alpha.8 is not published to npm yet. + # Pinned by commit SHA so the tarball cannot move under us; + # CONFORMANCE_PKG_SHA256 pins the bytes and the fetch-and-verify step below + # downloads, checks the digest, and repoints CONFORMANCE_PKG at the + # verified local copy. Repin to the next published @modelcontextprotocol/ + # conformance release (>=0.2.0-alpha.8) once it ships, then drop + # CONFORMANCE_PKG_SHA256 and the fetch-and-verify steps. + CONFORMANCE_PKG: "https://pkg.pr.new/@modelcontextprotocol/conformance@4944b268" + CONFORMANCE_PKG_SHA256: "0f70c035782d319d72ab427653c5275db5c50429d59fae0241a645b33aeda1a7" + +jobs: + server-conformance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + version: 0.9.5 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + - name: Fetch and verify conformance harness + # Only when CONFORMANCE_PKG is a URL: download, check the recorded + # sha256, and re-point CONFORMANCE_PKG at the verified local tarball. + # When CONFORMANCE_PKG is a registry spec, this step is a no-op (npm's + # own integrity check applies). + run: | + case "$CONFORMANCE_PKG" in + https://*) + curl -fsSL "$CONFORMANCE_PKG" -o /tmp/conformance.tgz + echo "$CONFORMANCE_PKG_SHA256 /tmp/conformance.tgz" | sha256sum -c - + echo "CONFORMANCE_PKG=file:/tmp/conformance.tgz" >> "$GITHUB_ENV" + ;; + esac + - run: uv sync --frozen --all-extras --package mcp-everything-server + - name: Run server conformance (active suite) + run: >- + ./.github/actions/conformance/run-server.sh + --suite active + --expected-failures ./.github/actions/conformance/expected-failures.yml + --output-dir conformance-results/server-active + - name: Run server conformance (draft suite) + run: >- + ./.github/actions/conformance/run-server.sh + --suite draft + --expected-failures ./.github/actions/conformance/expected-failures.yml + --output-dir conformance-results/server-draft + - name: Run server conformance (2026-07-28 wire, all suite) + run: >- + ./.github/actions/conformance/run-server.sh + --suite all + --spec-version 2026-07-28 + --expected-failures ./.github/actions/conformance/expected-failures.2026-07-28.yml + --output-dir conformance-results/server-2026-07-28 + - name: Run server conformance (all suite, extension scenarios) + # A bare `--suite all` (no --spec-version) selects every scenario + # shipped with the pinned harness — including the extension-tagged + # tasks-* scenarios and pending-listed ones like server-sse-polling, + # which no other leg reaches (extension scenarios never match a + # --spec-version filter, and the pending list keeps them out of the + # active suite). Running the full set keeps unimplemented surfaces + # visible as baselined known failures in expected-failures.yml instead + # of silent exclusions, and stays robust to scenarios moving between + # harness suite lists across pin bumps. `--suite pending` would cover + # the same union slightly faster; the full set is preferred for the + # self-contained run and for parity with typescript-sdk's CI. + run: >- + ./.github/actions/conformance/run-server.sh + --suite all + --expected-failures ./.github/actions/conformance/expected-failures.yml + --output-dir conformance-results/server-all + - name: Upload conformance results + # The log has only summary counts; per-check data is in checks.json. + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: server-conformance-results + path: conformance-results/ + if-no-files-found: ignore + + client-conformance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + version: 0.9.5 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + - name: Fetch and verify conformance harness + # Only when CONFORMANCE_PKG is a URL: download, check the recorded + # sha256, and re-point CONFORMANCE_PKG at the verified local tarball. + # When CONFORMANCE_PKG is a registry spec, this step is a no-op (npm's + # own integrity check applies). + run: | + case "$CONFORMANCE_PKG" in + https://*) + curl -fsSL "$CONFORMANCE_PKG" -o /tmp/conformance.tgz + echo "$CONFORMANCE_PKG_SHA256 /tmp/conformance.tgz" | sha256sum -c - + echo "CONFORMANCE_PKG=file:/tmp/conformance.tgz" >> "$GITHUB_ENV" + ;; + esac + # --compile-bytecode: without it, ~40 concurrently spawned interpreters + # race to byte-compile site-packages during the timing-sensitive window. + - run: uv sync --frozen --all-extras --package mcp --compile-bytecode + - name: Pre-compile bytecode (editable sources) + run: uv run --frozen python -m compileall -q src .github/actions/conformance + - name: Run client conformance (all suite) + # The harness runs all scenarios via unbounded Promise.all; with 40 + # scenarios on a 2-core runner the slowest one (sse-retry, which has a + # real-time SSE reconnect wait) needs more than the 30s default budget. + # `.venv/bin/python` (not `uv run`) avoids lockfile re-checks in ~40 + # concurrent spawns; run-client.sh re-runs unexpected failures solo. + run: >- + ./.github/actions/conformance/run-client.sh + --command '.venv/bin/python .github/actions/conformance/client.py' + --suite all + --timeout 60000 + --expected-failures ./.github/actions/conformance/expected-failures.yml + --output-dir conformance-results/client-all + - name: Run client conformance (2026-07-28 wire, all suite) + run: >- + ./.github/actions/conformance/run-client.sh + --command '.venv/bin/python .github/actions/conformance/client.py' + --suite all + --timeout 60000 + --spec-version 2026-07-28 + --expected-failures ./.github/actions/conformance/expected-failures.2026-07-28.yml + --output-dir conformance-results/client-2026-07-28 + - name: Upload conformance results + # The log has only summary counts; per-check data is in checks.json. + # Also on FLAKE_RESCUED: rescued-flake evidence is otherwise discarded. + if: failure() || hashFiles('conformance-results/**/FLAKE_RESCUED') != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: client-conformance-results + path: conformance-results/ + if-no-files-found: ignore diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000..6da800a --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,64 @@ +name: Deploy Docs + +on: + push: + branches: + - main + - v1.x + paths: + - docs/** + # docs pages include their code blocks from these files via `--8<--`, so a + # change here changes the rendered site even when no .md file moves. + - docs_src/** + - mkdocs.yml + - src/mcp/** + - src/mcp-types/** + - scripts/build-docs.sh + - scripts/docs/** + - pyproject.toml + - uv.lock + - .github/workflows/deploy-docs.yml + workflow_dispatch: + +concurrency: + group: deploy-docs + cancel-in-progress: false + +jobs: + deploy-docs: + runs-on: ubuntu-latest + + permissions: + contents: read + pages: write + id-token: write + + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + version: 0.9.5 + + - name: Build combined docs (v1.x at /, main at /v2/) + run: bash scripts/build-docs.sh site + + - name: Configure Pages + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: site + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.github/workflows/docs-preview-cleanup.yml b/.github/workflows/docs-preview-cleanup.yml new file mode 100644 index 0000000..136a50e --- /dev/null +++ b/.github/workflows/docs-preview-cleanup.yml @@ -0,0 +1,44 @@ +name: Docs Preview Cleanup + +# Deletes Cloudflare Pages preview deployments for a PR when it closes. +# Runs as pull_request_target so secrets are available for fork PRs; it never +# checks out PR code, so there is no untrusted-code execution risk. + +on: + pull_request_target: # zizmor: ignore[dangerous-triggers] never checks out PR code + types: [closed] + +permissions: {} + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - name: Delete preview deployments for this PR + env: + CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CF_PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT }} + BRANCH: pr-${{ github.event.pull_request.number }} + run: | + set -euo pipefail + if [ -z "$CF_API_TOKEN" ] || [ -z "$CF_ACCOUNT_ID" ] || [ -z "$CF_PROJECT" ]; then + echo "Cloudflare credentials/project not configured; skipping cleanup." + exit 0 + fi + base="https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/pages/projects/$CF_PROJECT/deployments" + # Collect matching ids across all pages first, then delete — deleting + # mid-pagination would shift later pages and skip entries. + ids="" + for page in $(seq 1 200); do + resp=$(curl -fsS -H "Authorization: Bearer $CF_API_TOKEN" "$base?env=preview&per_page=25&page=$page") + ids="$ids $(jq -r --arg b "$BRANCH" '.result[]? | select(.deployment_trigger.metadata.branch == $b) | .id' <<<"$resp")" + [ "$(jq '.result | length' <<<"$resp")" -lt 25 ] && break + done + deleted=0 + for id in $ids; do + echo "Deleting deployment $id" + curl -fsS -X DELETE -H "Authorization: Bearer $CF_API_TOKEN" "$base/$id?force=true" > /dev/null + deleted=$((deleted + 1)) + done + echo "Deleted $deleted deployment(s) for $BRANCH." diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml new file mode 100644 index 0000000..6f9ec2c --- /dev/null +++ b/.github/workflows/docs-preview.yml @@ -0,0 +1,249 @@ +name: Docs Preview + +# Builds the docs site for a PR and deploys it to Cloudflare Pages. +# +# Security: the build executes Python from the PR (mkdocstrings imports +# src/mcp, `!!python/name:` config directives run, and heads may ship their +# own build scripts). The build is gated by `authorize` (admin sender for +# auto-preview, admin/maintainer commenter for /preview-docs) and isolated +# from Cloudflare secrets — `build` runs PR code with no secrets and hands +# the static site to `deploy` via an artifact, so PR code never shares a +# runner with the Cloudflare token. +# +# Required configuration: +# - secrets.CLOUDFLARE_API_TOKEN (scope: Account → Cloudflare Pages → Edit) +# - secrets.CLOUDFLARE_ACCOUNT_ID +# - vars.CLOUDFLARE_PAGES_PROJECT (existing Pages project, e.g. mcp-python-sdk-docs) + +on: + pull_request_target: # zizmor: ignore[dangerous-triggers] build is permission-gated and secret-isolated; see header comment + types: [opened, reopened, synchronize] + paths: + - docs/** + - docs_src/** + - mkdocs.yml + - scripts/docs/** + - pyproject.toml + issue_comment: + types: [created] + +permissions: {} + +concurrency: + # Workflow-level concurrency is evaluated when the run is queued — before any + # job-level `if:` — so an unrelated PR comment would otherwise cancel an + # in-flight build. Only runs that actually produce a preview share a group; + # everything else falls through to a unique run_id group. + group: >- + docs-preview-pr-${{ + github.event_name == 'pull_request_target' && github.event.pull_request.number + || (github.event.issue.pull_request && startsWith(github.event.comment.body, '/preview-docs') && github.event.issue.number) + || github.run_id + }} + cancel-in-progress: true + +jobs: + authorize: + if: >- + github.event_name == 'pull_request_target' || + (github.event.issue.pull_request && startsWith(github.event.comment.body, '/preview-docs')) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + authorized: ${{ steps.check.outputs.authorized }} + pr_number: ${{ steps.check.outputs.pr_number }} + head_sha: ${{ steps.check.outputs.head_sha }} + slash_attempt: ${{ steps.check.outputs.slash_attempt }} + steps: + - name: Determine authorization + id: check + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const { owner, repo } = context.repo; + + async function permissionFor(username) { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username }); + return { level: data.permission, role: data.role_name }; + } + + let authorized = false; + let prNumber = ''; + let headSha = ''; + let slashAttempt = false; + + if (context.eventName === 'pull_request_target') { + // Gate on the *sender* (whoever caused this run — on synchronize that + // is the pusher), not the PR author, so a non-admin pushing to an + // admin-opened branch does not get an automatic build. + const actor = context.payload.sender.login; + prNumber = String(context.payload.pull_request.number); + headSha = context.payload.pull_request.head.sha; + const perm = await permissionFor(actor); + authorized = perm.level === 'admin'; + core.info(`pull_request_target by ${actor} (level=${perm.level}, role=${perm.role}) → authorized=${authorized}`); + } else { + // issue_comment: the job-level `if:` already guarantees this is a PR + // comment starting with /preview-docs. + slashAttempt = true; + const actor = context.payload.comment.user.login; + prNumber = String(context.payload.issue.number); + const perm = await permissionFor(actor); + authorized = perm.level === 'admin' || perm.role === 'maintain'; + if (authorized) { + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: Number(prNumber) }); + if (pr.state !== 'open') { + authorized = false; + core.info(`PR #${prNumber} is ${pr.state}; refusing to preview.`); + } else { + headSha = pr.head.sha; + } + } + core.info(`/preview-docs by ${actor} (level=${perm.level}, role=${perm.role}) → authorized=${authorized}`); + } + + core.setOutput('authorized', String(authorized)); + core.setOutput('pr_number', prNumber); + core.setOutput('head_sha', headSha); + core.setOutput('slash_attempt', String(slashAttempt)); + + build: + needs: authorize + if: needs.authorize.outputs.authorized == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ needs.authorize.outputs.head_sha }} + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + # pull_request_target runs share the base-branch Actions cache; saving + # a cache populated while untrusted PR code ran would let it poison + # later trusted workflows. Mirrors publish-pypi.yml. + enable-cache: false + version: 0.9.5 + + # pull_request_target runs this workflow file from the base branch, so + # the whole recipe — dependency sync included — must come from the + # checkout itself: heads that ship scripts/docs/build.sh (the Zensical + # toolchain) build with it; older heads, and v1.x heads previewed via + # /preview-docs, still build with MkDocs. Both arms must write the site + # to site/. Keep the detection in sync with build_site() in + # scripts/build-docs.sh. + - run: | + if [ -f scripts/docs/build.sh ]; then + bash scripts/docs/build.sh + else + uv sync --frozen --group docs + # The env var silences mkdocs-material's MkDocs 2.0 warning banner. + NO_MKDOCS_2_WARNING=1 uv run --frozen --no-sync mkdocs build + fi + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: site + path: site/ + retention-days: 1 + # An empty site/ means the build arm broke its output contract; fail + # here instead of surfacing as a confusing download error in deploy. + if-no-files-found: error + + deploy: + needs: [authorize, build] + if: needs.authorize.outputs.authorized == 'true' + runs-on: ubuntu-latest + permissions: {} + outputs: + deployment_url: ${{ steps.wrangler.outputs.deployment-url }} + alias_url: ${{ steps.wrangler.outputs.pages-deployment-alias-url }} + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: site + path: site + + - name: Deploy to Cloudflare Pages + id: wrangler + uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + packageManager: npm + command: >- + pages deploy ./site + --project-name=${{ vars.CLOUDFLARE_PAGES_PROJECT }} + --branch=pr-${{ needs.authorize.outputs.pr_number }} + --commit-hash=${{ needs.authorize.outputs.head_sha }} + --commit-dirty=true + + comment: + needs: [authorize, build, deploy] + if: >- + always() && + needs.deploy.result != 'cancelled' && + (needs.authorize.outputs.authorized == 'true' || needs.authorize.outputs.slash_attempt == 'true') + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Post or update preview comment + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + AUTHORIZED: ${{ needs.authorize.outputs.authorized }} + PR_NUMBER: ${{ needs.authorize.outputs.pr_number }} + HEAD_SHA: ${{ needs.authorize.outputs.head_sha }} + DEPLOY_RESULT: ${{ needs.deploy.result }} + DEPLOYMENT_URL: ${{ needs.deploy.outputs.deployment_url }} + ALIAS_URL: ${{ needs.deploy.outputs.alias_url }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + with: + script: | + const { owner, repo } = context.repo; + const env = process.env; + const issue_number = Number(env.PR_NUMBER); + const marker = '<!-- docs-preview -->'; + + async function upsert(body) { + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 }); + const existing = comments.find(c => c.user?.login === 'github-actions[bot]' && c.body?.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number, body }); + } + } + + if (env.AUTHORIZED !== 'true') { + await github.rest.issues.createComment({ + owner, repo, issue_number, + body: `@${context.actor} — only repository admins or maintainers can run \`/preview-docs\` (and the PR must be open).`, + }); + return; + } + + if (env.DEPLOY_RESULT !== 'success') { + await upsert( + `${marker}\n### 📚 Documentation preview\n\n` + + `❌ Preview build **failed** for \`${env.HEAD_SHA.slice(0, 7)}\` — [workflow logs](${env.RUN_URL}).` + ); + return; + } + + const previewUrl = env.ALIAS_URL || env.DEPLOYMENT_URL; + const ts = new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC'); + await upsert( + `${marker}\n### 📚 Documentation preview\n\n` + + `| | |\n|---|---|\n` + + `| **Preview** | ${previewUrl} |\n` + + `| **Deployment** | ${env.DEPLOYMENT_URL} |\n` + + `| **Commit** | \`${env.HEAD_SHA.slice(0, 7)}\` |\n` + + `| **Triggered by** | @${context.actor} |\n` + + `| **Updated** | ${ts} |\n` + ); diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..341df0a --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,23 @@ +name: CI + +on: + push: + branches: ["main", "v1.x"] + tags: ["v*.*.*"] + pull_request: + +permissions: + contents: read + +jobs: + checks: + uses: ./.github/workflows/shared.yml + + all-green: + if: always() + needs: [checks] + runs-on: ubuntu-latest + steps: + - uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2 + with: + jobs: ${{ toJSON(needs) }} diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 0000000..41b127f --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,64 @@ +name: Publishing + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + release-build: + name: Build distribution + runs-on: ubuntu-latest + needs: [checks] + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: false + version: 0.9.5 + + - name: Set up Python 3.12 + run: uv python install 3.12 + + - name: Build + run: | + uv build --package mcp + uv build --package mcp-types + + - name: Upload artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-dists + path: dist/ + + checks: + uses: ./.github/workflows/shared.yml + + pypi-publish: + name: Upload release to PyPI + runs-on: ubuntu-latest + environment: release + needs: + - release-build + permissions: + id-token: write # IMPORTANT: this permission is mandatory for trusted publishing + + steps: + - name: Retrieve release distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-dists + path: dist/ + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 + with: + # Lets a re-run after a partially failed upload publish the remaining + # files instead of erroring on the ones already on PyPI. + skip-existing: true diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml new file mode 100644 index 0000000..c859bba --- /dev/null +++ b/.github/workflows/shared.yml @@ -0,0 +1,177 @@ +name: Shared Checks + +on: + workflow_call: + +permissions: + contents: read + +env: + COLUMNS: 150 + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + # setup-uv's manifest fetch is a single request with a hard 5s timeout + # (astral-sh/setup-uv#869); retry once. Drop when upstream adds a retry. + - name: Install uv + id: setup-uv + continue-on-error: true + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + version: 0.9.5 + + - name: Install uv (retry) + if: steps.setup-uv.outcome == 'failure' + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + version: 0.9.5 + + - name: Install dependencies + run: uv sync --frozen --all-extras --python 3.10 + + - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 + with: + extra_args: --all-files --verbose + + - name: Surface types match vendored schema + run: | + uv sync --group codegen --frozen + uv run --frozen --group codegen python scripts/gen_surface_types.py --check + + # Resolves only mcp-types' declared dependencies into an empty environment, + # so an import of the SDK or anything from its stack fails here. + - name: mcp-types installs and imports standalone + run: | + uv run --isolated --no-project --with ./src/mcp-types python -c \ + "import mcp_types, mcp_types.jsonrpc, mcp_types.methods, mcp_types.version, mcp_types.v2025_11_25, mcp_types.v2026_07_28" + + test: + name: test (${{ matrix.python-version }}, ${{ matrix.dep-resolution.name }}, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + dep-resolution: + - name: lowest-direct + install-flags: "--upgrade --resolution lowest-direct" + - name: locked + install-flags: "--frozen" + os: [ubuntu-latest, windows-latest] + + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + # setup-uv's manifest fetch is a single request with a hard 5s timeout + # (astral-sh/setup-uv#869); retry once. Drop when upstream adds a retry. + - name: Install uv + id: setup-uv + continue-on-error: true + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + version: 0.9.5 + + - name: Install uv (retry) + if: steps.setup-uv.outcome == 'failure' + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + version: 0.9.5 + + - name: Install the project + run: uv sync ${{ matrix.dep-resolution.install-flags }} --all-extras --python ${{ matrix.python-version }} + + - name: Run pytest with coverage + shell: bash + env: + # tests/examples/test_stories_smoke.py is gated on this var; it spawns real + # stdio + uvicorn subprocesses, so run it on exactly one matrix cell. + MCP_EXAMPLES_SMOKE: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' && matrix.dep-resolution.name == 'locked' && '1' || '' }} + run: | + uv run --frozen --no-sync coverage erase + uv run --frozen --no-sync coverage run -m pytest -n auto + uv run --frozen --no-sync coverage combine + uv run --frozen --no-sync coverage report + + - name: Check for unnecessary no cover pragmas + if: runner.os != 'Windows' + run: uv run --frozen --no-sync strict-no-cover + + readme-snippets: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + # setup-uv's manifest fetch is a single request with a hard 5s timeout + # (astral-sh/setup-uv#869); retry once. Drop when upstream adds a retry. + - name: Install uv + id: setup-uv + continue-on-error: true + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + version: 0.9.5 + + - name: Install uv (retry) + if: steps.setup-uv.outcome == 'failure' + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + version: 0.9.5 + + - 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 + + # `scripts/docs/build.sh` is the whole gauntlet: build_config.py fails on + # nav entries without a page and pages without a nav entry, `zensical build + # --strict` fails on broken .md links, `pymdownx.snippets: check_paths: + # true` fails on a deleted `docs_src/` include, and the post-build steps + # fail on unresolved cross-references, inventory download failures, and + # broken non-markdown link targets. + # Until this job existed the docs were only ever built post-merge by + # `deploy-docs.yml`, so those failures went green on the PR and broke the next + # deploy of main. This is the check path; `deploy-docs.yml` stays the deploy + # path. + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + # setup-uv's manifest fetch is a single request with a hard 5s timeout + # (astral-sh/setup-uv#869); retry once. Drop when upstream adds a retry. + - name: Install uv + id: setup-uv + continue-on-error: true + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + version: 0.9.5 + + - name: Install uv (retry) + if: steps.setup-uv.outcome == 'failure' + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + version: 0.9.5 + + - name: Build the docs in strict mode + run: bash scripts/docs/build.sh diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 0000000..f66ff74 --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,25 @@ +name: GitHub Actions Security Analysis + +on: + push: + branches: ["main"] + pull_request: + branches: ["**"] + +permissions: {} + +jobs: + zizmor: + runs-on: ubuntu-latest + + permissions: + security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files. + + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Run zizmor 🌈 + uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e788e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,182 @@ +.DS_Store +scratch/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +.ruff_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# documentation +/site +/.worktrees/ +# Generated at build time by scripts/docs/ (the API reference tree and the +# concrete Zensical config spliced from mkdocs.yml). +/docs/api/ +/mkdocs.gen.yml + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +# vscode +.vscode/ +.windsurfrules +**/CLAUDE.local.md + +# claude code +results/ + +# conformance CI local runs +conformance-results/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..321b60b --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,62 @@ +fail_fast: true + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: end-of-file-fixer + + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v3.1.0 + hooks: + - id: prettier + types_or: [yaml, json5] + + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.45.0 + hooks: + - id: markdownlint + args: + [ + "--fix", + "--config", + "pyproject.toml", + "--configPointer", + "/tool/markdown/lint", + ] + types: [markdown] + + - repo: local + hooks: + - id: ruff-format + name: Ruff Format + entry: uv run --frozen ruff + args: [format] + language: system + types: [python] + pass_filenames: false + - id: ruff + name: Ruff + entry: uv run --frozen ruff + args: ["check", "--fix", "--exit-non-zero-on-fix"] + types: [python] + language: system + pass_filenames: false + - id: pyright + name: pyright + entry: uv run --frozen pyright + language: system + types: [python] + pass_filenames: false + - id: uv-lock-check + name: Check uv.lock is up to date + entry: uv lock --check + language: system + files: ^(pyproject\.toml|uv\.lock)$ + pass_filenames: false + - id: readme-snippets + name: Check README snippets are up to date + entry: uv run --frozen python scripts/update_readme_snippets.py --check + language: system + files: ^(README\.md|docs_src/.*\.py|scripts/update_readme_snippets\.py)$ + pass_filenames: false diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..43fbb88 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,162 @@ +# Development Guidelines + +## Branching Model + +<!-- TODO: drop this section once v2 ships and main becomes the stable line --> + +- `main` is currently the V2 rework. +- Breaking changes are expected here — removing or replacing an API must be + intentional. Adding a replacement API or `@deprecated` shim must likewise be + a deliberate design choice, not bolted on for free. +- Breaking changes (including those softened by a backwards-compatibility + shim) must be documented in `docs/migration.md`. +- `v1.x` is the release branch for the current stable line. Backport PRs target + this branch and use a `[v1.x]` title prefix. +- `README.md` documents v2. The v1 README lives on the `v1.x` branch. + +## Package Management + +- ONLY use uv, NEVER pip +- Installation: `uv add <package>`. Exception: the root project's runtime + dependencies are dynamic (the published `mcp` wheel exact-pins `mcp-types`), + so `uv add` cannot edit them — add the requirement to + `[tool.hatch.metadata.hooks.uv-dynamic-versioning].dependencies` in + `pyproject.toml` by hand, then run `uv lock`. Dependency groups, extras, and + the example packages still take plain `uv add`. +- Running tools: `uv run --frozen <tool>`. Always pass `--frozen` so uv doesn't + rewrite `uv.lock` as a side effect. +- Cross-version testing: `uv run --frozen --python 3.10 pytest ...` to run + against a specific interpreter (CI covers 3.10–3.14). +- Upgrading: `uv lock --upgrade-package <package>` +- FORBIDDEN: `uv pip install`, `@latest` syntax +- Don't raise dependency floors for CVEs alone. The `>=` constraint already + lets users upgrade. Only raise a floor when the SDK needs functionality from + the newer version, and don't add SDK code to work around a dependency's + vulnerability. See Kludex/uvicorn#2643 and python-sdk #1552 for reasoning. + +## Code Quality + +- Type hints required for all code +- Public APIs must have docstrings. When a public API raises exceptions a + caller would reasonably catch, document them in a `Raises:` section. Don't + list exceptions from argument validation or programmer error. +- `src/mcp/__init__.py` defines the public API surface via `__all__`. Adding a + symbol there is a deliberate API decision, not a convenience re-export. +- IMPORTANT: All imports go at the top of the file — inline imports hide + dependencies and obscure circular-import bugs. Only exception: when a + top-level import genuinely can't work (lazy-loading optional deps, or + tests that re-import a module). + +## Testing + +- When writing or reviewing tests, conform to `.claude/skills/test-quality/SKILL.md` + — it defines the bar for naming, abstraction level, assertions, and determinism. +- Framework: `uv run --frozen pytest` +- Async testing: use anyio, not asyncio +- Do not use `Test` prefixed classes — write plain top-level `test_*` functions. + Legacy files still contain `Test*` classes; do NOT follow that pattern for new + tests even when adding to such a file. +- IMPORTANT: Tests should be fast and deterministic. Prefer in-memory async execution; + reach for threads only when necessary, and subprocesses only as a last resort. +- For end-to-end behavior, an in-memory `Client(server)` is usually the + cleanest approach (see `tests/client/test_client.py` for the canonical + pattern). For narrower changes, testing the function directly is fine. Use + judgment. +- Test files mirror the source tree: `src/mcp/client/stdio.py` → + `tests/client/test_stdio.py`. Add tests to the existing file for that module. +- Avoid `anyio.sleep()` with a fixed duration to wait for async operations. Instead: + - Use `anyio.Event` — set it in the callback/handler, `await event.wait()` in the test + - For stream messages, use `await stream.receive()` instead of `sleep()` + `receive_nowait()` + - Exception: `sleep()` is appropriate when testing time-based features (e.g., timeouts) +- Wrap indefinite waits (`event.wait()`, `stream.receive()`) in `anyio.fail_after(5)` to prevent hangs +- Pytest is configured with `filterwarnings = ["error"]`, so warnings fail + tests. Don't silence warnings from your own code; fix the underlying cause. + Scoped `ignore::` entries for upstream libraries are acceptable in + `pyproject.toml` with a comment explaining why. +- New features from the 2026-07-28 spec must have a matching test in the + [conformance suite](https://github.com/modelcontextprotocol/conformance) + that passes against this SDK (CI runs it via + `.github/workflows/conformance.yml`). If no matching test exists, stop and + tell the user so they can raise an issue on the conformance repo. + +### Coverage + +CI requires 100% (`fail_under = 100`, `branch = true`). + +- Full check: `./scripts/test` (~23s). Runs coverage + `strict-no-cover` on the + default Python. Not identical to CI: CI runs 3.10–3.14 × {ubuntu, windows} + × {locked, lowest-direct}, and some branch-coverage quirks only surface on + specific matrix entries. +- Targeted check while iterating (~4s, deterministic): + + ```bash + uv run --frozen coverage erase + uv run --frozen coverage run -m pytest tests/path/test_foo.py + uv run --frozen coverage combine + uv run --frozen coverage report --include='src/mcp/path/foo.py' --fail-under=0 + # UV_FROZEN=1 propagates --frozen to the uv subprocess strict-no-cover spawns + UV_FROZEN=1 uv run --frozen strict-no-cover + ``` + + Partial runs can't hit 100% (coverage tracks `tests/` too), so `--fail-under=0` + and `--include` scope the report. `strict-no-cover` has no false positives on + partial runs — if your new test executes a line marked `# pragma: no cover`, + even a single-file run catches it. + +Avoid adding new `# pragma: no cover`, `# type: ignore`, or `# noqa` comments. +In tests, use `assert isinstance(x, T)` to narrow types instead of +`# type: ignore`. In library code (`src/`), a `# pragma: no cover` needs very +good reasoning — it usually means a test is missing. Audit before pushing: + +```bash +git diff origin/main... | grep -E '^\+.*(pragma|type: ignore|noqa)' +``` + +What the existing pragmas mean: + +- `# pragma: no cover` — line is never executed. CI's `strict-no-cover` (skipped + on Windows runners) fails if it IS executed. When your test starts covering + such a line, remove the pragma. +- `# pragma: lax no cover` — excluded from coverage but not checked by + `strict-no-cover`. Use for lines covered on some platforms/versions but not + others. +- `# pragma: no branch` — excludes branch arcs only. coverage.py misreports the + `->exit` arc for nested `async with` on Python 3.11+ (worse on 3.14/Windows). + +## Breaking Changes + +When making breaking changes, document them in `docs/migration.md` — including +changes softened by a backwards-compatibility shim. Include: + +- What changed +- Why it changed +- How to migrate existing code + +Search for related sections in the migration guide and group related changes together +rather than adding new standalone sections. + +## Documentation + +When a change affects public API or user-visible behaviour, update the relevant +page(s) under `docs/` in the same PR. Docs are organised by the `nav:` sections +in `mkdocs.yml` (Get started, Servers, Inside your handler, Running your server, +Clients, Advanced), not by the on-disk directory names. Find the page covering +the feature you touched in `mkdocs.yml` rather than adding a new one. + +## Formatting & Type Checking + +- Format: `uv run --frozen ruff format .` +- Lint: `uv run --frozen ruff check . --fix` +- Type check: `uv run --frozen pyright` +- Pre-commit runs all of the above plus markdownlint, a `uv.lock` consistency + check, and README checks — see `.pre-commit-config.yaml` + +## Exception Handling + +- **Always use `logger.exception()` instead of `logger.error()` when catching exceptions** + - Don't include the exception in the message: `logger.exception("Failed")` not `logger.exception(f"Failed: {e}")` +- **Catch specific exceptions** where possible: + - File ops: `except (OSError, PermissionError):` + - JSON: `except json.JSONDecodeError:` + - Network: `except (ConnectionError, TimeoutError):` +- **FORBIDDEN** `except Exception:` - unless in top-level handlers diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..985a285 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +<mcp-coc@anthropic.com>. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +<https://www.contributor-covenant.org/version/2/0/code_of_conduct.html>. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +<https://www.contributor-covenant.org/faq>. Translations are available at +<https://www.contributor-covenant.org/translations>. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a36dedd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,160 @@ +# Contributing + +Thank you for your interest in contributing to the MCP Python SDK! This document provides guidelines and instructions for contributing. + +## Before You Start + +We welcome contributions! These guidelines exist to save everyone time, yours included. Following them means your work is more likely to be accepted. + +**All pull requests require a corresponding issue.** Unless your change is trivial (typo, docs tweak, broken link), create an issue first. Every merged feature becomes ongoing maintenance, so we need to agree something is worth doing before reviewing code. PRs without a linked issue will be closed. + +Having an issue doesn't guarantee acceptance. Wait for maintainer feedback or a `ready for work` label before starting. PRs for issues without buy-in may also be closed. + +Use issues to validate your idea before investing time in code. PRs are for execution, not exploration. + +### AI-Assisted Contributions + +> [!IMPORTANT] +> If you used AI assistance for a contribution, disclose it in the PR or issue. + +We use AI tooling constantly and have no problem with you using it too. But somewhere in the loop there has to be a human who actually understands the change. We have a large backlog and limited reviewer time—we're not spending it on code nobody has read. Not disclosing is also just rude to the people on the other end. + +- **Disclose it.** One line in the PR or issue description. That's it. +- **Own it.** You can explain the change in your own words. When a maintainer asks a question, the answer comes from you, not pasted from a chat window. +- **No drive-by agents.** PRs, issues, or comments produced by an autonomous agent with no human review get closed on sight. If your agent is auto-filing PRs against our open issues, stop. + +Undisclosed AI contributions get closed. Repeat offenders get banned from the `modelcontextprotocol` org. + +### The SDK is Opinionated + +Not every contribution will be accepted, even with a working implementation. We prioritize maintainability and consistency over adding capabilities. This is at maintainers' discretion. + +### What Needs Discussion + +These always require an issue first: + +- New public APIs or decorators +- Architectural changes or refactoring +- Changes that touch multiple modules +- Features that might require spec changes (these need a [SEP](https://github.com/modelcontextprotocol/modelcontextprotocol) first) + +Bug fixes for clear, reproducible issues are welcome—but still create an issue to track the fix. + +### Finding Issues to Work On + +| Label | For | Description | +|-------|-----|-------------| +| [`good first issue`](https://github.com/modelcontextprotocol/python-sdk/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) | Newcomers | Can tackle without deep codebase knowledge | +| [`help wanted`](https://github.com/modelcontextprotocol/python-sdk/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22) | Experienced contributors | Maintainers probably won't get to this | +| [`ready for work`](https://github.com/modelcontextprotocol/python-sdk/issues?q=is%3Aopen+is%3Aissue+label%3A%22ready+for+work%22) | Maintainers | Triaged and ready for a maintainer to pick up | + +Issues labeled `needs confirmation` or `needs maintainer action` are **not** ready for work—wait for maintainer input first. + +Before starting, comment on the issue so we can assign it to you. This prevents duplicate effort. + +## Development Setup + +1. Make sure you have Python 3.10+ installed +2. Install [uv](https://docs.astral.sh/uv/getting-started/installation/) +3. Fork the repository +4. Clone your fork: `git clone https://github.com/YOUR-USERNAME/python-sdk.git` +5. Install dependencies: + +```bash +uv sync --frozen --all-extras --dev +``` + +6. Set up pre-commit hooks: + +```bash +uv tool install pre-commit --with pre-commit-uv --force-reinstall +``` + +## Development Workflow + +1. Choose the correct branch for your changes: + + | Change Type | Target Branch | Example | + |-------------|---------------|---------| + | New features, breaking changes | `main` | New APIs, refactors | + | Security fixes for v1 | `v1.x` | Critical patches | + | Bug fixes for v1 | `v1.x` | Non-breaking fixes | + + > **Note:** `main` is the v2 development branch. Breaking changes are welcome on `main`. The `v1.x` branch receives only security and critical bug fixes. + +2. Create a new branch from your chosen base branch + +3. Make your changes + +4. Ensure tests pass: + +```bash +uv run pytest +``` + +5. Run type checking: + +```bash +uv run pyright +``` + +6. Run linting: + +```bash +uv run ruff check . +uv run ruff format . +``` + +7. Update README snippets if you modified `docs_src/` code embedded in the README: + +```bash +uv run scripts/update_readme_snippets.py +``` + +8. (Optional) Run pre-commit hooks on all files: + +```bash +pre-commit run --all-files +``` + +9. Submit a pull request to the same branch you branched from + +## Code Style + +- We use `ruff` for linting and formatting +- Follow PEP 8 style guidelines +- Add type hints to all functions +- Include docstrings for public APIs + +## Pull Requests + +By the time you open a PR, the "what" and "why" should already be settled in an issue. This keeps reviews focused on implementation. + +### Scope + +Small PRs get reviewed fast. Large PRs sit in the queue. + +A few dozen lines can be reviewed in minutes. Hundreds of lines across many files takes real effort and things slip through. If your change is big, break it into smaller PRs or get alignment from a maintainer first. + +### What Gets Rejected + +- **No prior discussion**: Features or significant changes without an approved issue +- **Scope creep**: Changes that go beyond what was discussed +- **Misalignment**: Even well-implemented features may be rejected if they don't fit the SDK's direction +- **Overengineering**: Unnecessary complexity for simple problems +- **Undisclosed or unreviewed AI output**: See [AI-Assisted Contributions](#ai-assisted-contributions) + +### Checklist + +1. Update documentation as needed +2. Add tests for new functionality +3. Ensure CI passes +4. Address review feedback + +## Code of Conduct + +Please note that this project is released with a [Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms. + +## License + +By contributing, you agree that your contributions will be licensed under the MIT License. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3d48435 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Anthropic, PBC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3822976 --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# MCP Python SDK + +<div align="center"> + +<strong>Python implementation of the Model Context Protocol (MCP)</strong> + +[![PyPI][pypi-badge]][pypi-url] +[![MIT licensed][mit-badge]][mit-url] +[![Python Version][python-badge]][python-url] +[![Documentation][docs-badge]][docs-url] +[![Protocol][protocol-badge]][protocol-url] +[![Specification][spec-badge]][spec-url] + +</div> + +> [!CAUTION] +> **This README documents v2 of the MCP Python SDK — a pre-release (alpha/beta) line under active development. Do not use v2 in production.** Pre-releases are published to PyPI as `2.0.0aN` / `2.0.0bN`, and **each pre-release may contain breaking changes from the previous one**. Pin an exact version and expect to update your code when you bump the pin. +> +> **v1.x is the only stable release line and remains recommended for production.** It lives on the [`v1.x` branch](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x) and continues to receive critical bug fixes and security patches; see [the v1.x README](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/README.md) for its documentation. `pip` and `uv` don't select a pre-release unless you explicitly request one, so existing installs are unaffected. **If your package depends on `mcp`, add a `<2` upper bound to your version constraint (for example `mcp>=1.27,<2`) before the stable release lands.** +> +> v2 is a major rework of the SDK, both to support the [2026-07-28 MCP specification release](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) and to fix long-standing architectural issues. See [What's new in v2](https://py.sdk.modelcontextprotocol.io/v2/whats-new/) for the tour of what changed, and the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/) for every breaking change. Stable v2 is targeted for 2026-07-27, alongside the spec release. Try the pre-releases and [tell us what breaks](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml), or discuss in [#python-sdk-dev on the MCP Contributors Discord](https://discord.gg/6CSzBmMkjX). + +## Documentation + +**The documentation lives at <https://py.sdk.modelcontextprotocol.io/v2/>.** + +It has a [Get started guide](https://py.sdk.modelcontextprotocol.io/v2/get-started/), [What's new in v2](https://py.sdk.modelcontextprotocol.io/v2/whats-new/), the [API reference](https://py.sdk.modelcontextprotocol.io/v2/api/mcp/), and the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/). + +## What is MCP? + +The [Model Context Protocol](https://modelcontextprotocol.io) lets you build servers that expose data and functionality to LLM applications in a secure, standardized way. Think of it like a web API, but designed for LLM interactions. With this SDK you can: + +- **Build MCP servers** that expose tools, resources, and prompts to any MCP host +- **Build MCP clients** that connect to any MCP server +- Speak every standard transport: stdio, Streamable HTTP, and SSE + +## Requirements + +Python 3.10+. + +## Installation + +```bash +uv add "mcp[cli]==2.0.0b1" # or: pip install "mcp[cli]==2.0.0b1" +``` + +The pin matters while v2 is in pre-release: an unpinned install resolves to the latest stable v1.x, which this README does not describe. Check [PyPI](https://pypi.org/project/mcp/#history) for the newest pre-release, and use `uv run --with "mcp==2.0.0b1"` for one-off commands. + +## A server in 15 lines + +Create a `server.py`: + +<!-- snippet-source docs_src/index/tutorial001.py --> +```python +from mcp.server import MCPServer + +mcp = MCPServer("Demo") + + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + +@mcp.resource("greeting://{name}") +def greeting(name: str) -> str: + """Greet someone by name.""" + return f"Hello, {name}!" +``` + +_Full example: [docs_src/index/tutorial001.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/docs_src/index/tutorial001.py)_ +<!-- /snippet-source --> + +That's a complete MCP server: one tool, one templated resource. Open it in the [MCP Inspector](https://github.com/modelcontextprotocol/inspector): + +```bash +uv run mcp dev server.py +``` + +Call `add` with `a=1`, `b=2` and you get `3` back. + +Notice what you did **not** write: no JSON Schema (`a: int, b: int` _is_ the schema), no request parsing, no validation code, no protocol handling. Two type-hinted Python functions and a docstring. + +[Get started](https://py.sdk.modelcontextprotocol.io/v2/get-started/) takes it from here. + +## A client in 10 lines + +The same package is a full MCP **client**. `Client` connects to a URL, a stdio subprocess, a custom transport, or (for tests) straight to a server object in memory with no transport at all: + +```python +import asyncio + +from mcp import Client + +from server import mcp + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + print(result.structured_content) # {'result': 3} + + +asyncio.run(main()) +``` + +Swap `mcp` for `"http://localhost:8000/mcp"` and the exact same code talks to a remote server. + +## Contributing + +We are passionate about supporting contributors of all levels of experience and would love to see you get involved in the project. See the [contributing guide](https://github.com/modelcontextprotocol/python-sdk/blob/main/CONTRIBUTING.md) to get started. + +## License + +This project is licensed under the MIT License. See the [LICENSE](https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE) file for details. + +[pypi-badge]: https://img.shields.io/pypi/v/mcp.svg +[pypi-url]: https://pypi.org/project/mcp/ +[mit-badge]: https://img.shields.io/pypi/l/mcp.svg +[mit-url]: https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE +[python-badge]: https://img.shields.io/pypi/pyversions/mcp.svg +[python-url]: https://www.python.org/downloads/ +[docs-badge]: https://img.shields.io/badge/docs-python--sdk-blue.svg +[docs-url]: https://py.sdk.modelcontextprotocol.io/v2/ +[protocol-badge]: https://img.shields.io/badge/protocol-modelcontextprotocol.io-blue.svg +[protocol-url]: https://modelcontextprotocol.io +[spec-badge]: https://img.shields.io/badge/spec-spec.modelcontextprotocol.io-blue.svg +[spec-url]: https://modelcontextprotocol.io/specification/latest diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..90b6a5f --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`modelcontextprotocol/python-sdk` +- 原始仓库:https://github.com/modelcontextprotocol/python-sdk +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..f86da2e --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,69 @@ +# Release Process + +## Bumping Dependencies + +1. Change the dependency version in `pyproject.toml`. The root `mcp` project's + runtime dependencies are dynamic and live under + `[tool.hatch.metadata.hooks.uv-dynamic-versioning].dependencies`. +2. Upgrade lock with `uv lock --resolution lowest-direct` + +## Major or Minor Release + +Stable releases are cut from the `v1.x` branch. Create a GitHub release via UI +with the tag being `vX.Y.Z` where `X.Y.Z` is the version and the release title +being the same, and **set the tag's target to the `v1.x` branch** — the UI +defaults to `main`, which is the v2 rework, and a v1 tag created there would +publish the v2 codebase as a stable release. Then ask someone to review the +release. + +The package version will be set automatically from the tag. + +## v2 Pre-releases + +v2 pre-releases are cut from `main` with a PEP 440 pre-release tag: `v2.0.0aN` +for alphas, later `bN`/`rcN` for betas and release candidates. + +A release publishes two distributions, `mcp` and `mcp-types`, at the same +version, and the `mcp` wheel exact-pins `mcp-types`. Before the first release +that includes both, the `mcp-types` PyPI project must be given the same +trusted publisher as `mcp` (this repository, workflow `publish-pypi.yml`, +environment `release`) and the same owners — without it the `mcp-types` +upload is rejected. If only some of the files upload, fix the cause and re-run +the publish job — `skip-existing` makes it skip whatever already landed. The +`Development Status` classifier in both `pyproject.toml` files is permanently +`5 - Production/Stable`; it is not bumped as part of any release. + +1. Update the pre-release version examples in `README.md` and the docs + (grep the outgoing version — the pins live in the README Installation + section, `docs/index.md`, `docs/get-started/installation.md`, and `docs/get-started/real-host.md`) so the tagged + commit — and therefore the README PyPI publishes — names the version + being released. When entering a new phase (alpha → beta → rc), update + the banner wording too. +2. Check the full test matrix is green on the release commit. The publish + workflow re-runs the checks and blocks publishing until they pass, so a + red leg there means re-running the failed jobs on the Publishing run. +3. Create the release as a pre-release, passing the exact commit verified in + step 2 as `--target` (otherwise the tag is created from whatever `main`'s + HEAD is by then). The tagged commit determines everything about the + release — the workflows that run and the package metadata (readme, + classifiers) that gets published — so it must contain the current release + tooling, not just pass tests. `--target` is ignored if the tag already + exists: when re-creating a release, delete the old tag first and + double-check where the new tag points. The pre-release flag keeps GitHub's + "Latest" badge and `/releases/latest` pointing at the stable v1.x line: + + ```shell + gh release create v2.0.0aN --prerelease --title v2.0.0aN --target <commit-sha> + ``` + +4. Curate the release notes instead of relying on auto-generated ones: what + changed since the previous pre-release, what is known-incomplete, the + install line (`pip install mcp==2.0.0aN`), and a link to the migration + guide. Use the absolute URL + (`https://github.com/modelcontextprotocol/python-sdk/blob/main/docs/migration.md`) + because relative links don't resolve in GitHub release bodies. +5. If a pre-release turns out to be broken, yank it on PyPI and cut the next + one. Never delete a release from PyPI — version numbers cannot be reused. + Yanking doesn't stop `==` pins from installing the broken version, so set + the yank reason (and edit the GitHub release notes) to point at the + replacement version. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e8b51cc --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,30 @@ +# Security Policy + +Thank you for helping keep the Model Context Protocol and its ecosystem secure. + +## Supported Versions + +Security fixes are released for the most recent stable (v1.x) release line. + +v2 pre-releases (`2.0.0aN`, …) are development snapshots: fixes land only in +the newest pre-release, and already-published pre-releases are not patched. If +you are testing the v2 line, track the latest pre-release; for production use, +stay on the latest stable release. + +## Reporting Security Issues + +If you discover a security vulnerability in this repository, please report it through +the [GitHub Security Advisory process](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) +for this repository. + +Please **do not** report security vulnerabilities through public GitHub issues, discussions, +or pull requests. + +## What to Include + +To help us triage and respond quickly, please include: + +- A description of the vulnerability +- Steps to reproduce the issue +- The potential impact +- Any suggested fixes (optional) diff --git a/docs/.overrides/.icons/mcp.svg b/docs/.overrides/.icons/mcp.svg new file mode 100644 index 0000000..67d800b --- /dev/null +++ b/docs/.overrides/.icons/mcp.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180"><path fill="none" d="M23.5996 85.2532L86.2021 22.6507C94.8457 14.0071 108.86 14.0071 117.503 22.6507C126.147 31.2942 126.147 45.3083 117.503 53.9519L70.2254 101.23" stroke="currentColor" stroke-width="11.0667" stroke-linecap="round"/><path fill="none" d="M70.8789 100.578L117.504 53.952C126.148 45.3083 140.163 45.3083 148.806 53.952L149.132 54.278C157.776 62.9216 157.776 76.9357 149.132 85.5792L92.5139 142.198C89.6327 145.079 89.6327 149.75 92.5139 152.631L104.14 164.257" stroke="currentColor" stroke-width="11.0667" stroke-linecap="round"/><path fill="none" d="M101.853 38.3013L55.553 84.6011C46.9094 93.2447 46.9094 107.258 55.553 115.902C64.1966 124.546 78.2106 124.546 86.8543 115.902L133.154 69.6025" stroke="currentColor" stroke-width="11.0667" stroke-linecap="round"/></svg> diff --git a/docs/advanced/apps.md b/docs/advanced/apps.md new file mode 100644 index 0000000..87e260f --- /dev/null +++ b/docs/advanced/apps.md @@ -0,0 +1,160 @@ +# MCP Apps + +An **MCP App** is a tool with a face: alongside its data, the tool points at an HTML +document the host renders as an interactive surface. + +Two parts, always two parts: + +1. **A tool** that does the work and returns data, like any other tool. +2. **A `ui://` resource** containing the HTML the host shows for it. + +The tool carries a `_meta.ui.resourceUri` reference to the resource. The host fetches +it with `resources/read`, renders it in a **sandboxed iframe**, and pushes the tool's +result into that iframe via `postMessage`. Your server never sends or receives any +`ui/*` messages: that traffic is between the host and the iframe. You serve a tool +and an HTML document; the host does the theater. + +The SDK ships this as the built-in `Apps` extension (`io.modelcontextprotocol/ui`). +If [Extensions](extensions.md) are new to you, skim that page first. One minute, +then come back. + +## A clock with a face + +```python title="server.py" hl_lines="18 21 29 31" +--8<-- "docs_src/apps/tutorial001.py" +``` + +Four moves: + +* `Apps()`: one instance holds your UI-bound tools and their resources. +* `@apps.tool(resource_uri="ui://clock/app.html")`: a regular tool, plus the + `_meta.ui.resourceUri` stamp. Everything `@mcp.tool()` accepts (name, title, + description, ...) passes through. +* `apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)`: the matching + resource, served as `text/html;profile=mcp-app`. That exact MIME type is what + tells a host "this is an app, render it". +* `MCPServer("clock", extensions=[apps])`: opt in. The server now advertises + `io.modelcontextprotocol/ui` under `capabilities.extensions`. + +The HTML itself listens for the host's `postMessage` and shows the result. For real +apps, use the official [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) +browser SDK inside your HTML. It gives you `ontoolresult`, `callServerTool`, +`getHostContext`, and `onhostcontextchanged` instead of raw message events. + +## Graceful degradation + +Not every client renders apps. The spec is blunt about what that means for you: + +> Tools **MUST** return a meaningful `content` array even when UI is available. + +The model reads `content`; the iframe is for humans. A UI-capable host still feeds +the text result to the model, and a text-only client gets *only* that. So the +canonical pattern is one tool, two answers. Look at `get_time` again: + +```python title="server.py" hl_lines="22-26" +--8<-- "docs_src/apps/tutorial001.py" +``` + +`client_supports_apps(ctx)` is `True` only when the client declared the +`io.modelcontextprotocol/ui` extension **and** listed `text/html;profile=mcp-app` +in its `mimeTypes` settings. The field is required, so a client that omits it +does not count. That is exactly what `main()` in the same file declares: the +client half of the negotiation, and the rich answer comes back. + +!!! warning + Never return a placeholder like `"[Rendered UI]"` as the only content. If the + fallback text is useless, the tool is useless to every text-only client and to + the model itself. Write the sentence. + +## Locking the iframe down + +The resource side carries the security metadata: what the iframe may load, which +browser permissions it wants, how it would like to be framed: + +```python title="server.py" hl_lines="9 19-22" +--8<-- "docs_src/apps/tutorial002.py" +``` + +`csp` and `permissions` are **requests to the host**, not server behaviour. The host +builds the iframe's Content-Security-Policy and Permissions-Policy from them, and it +may refuse. Feature-detect in your JS rather than assuming a grant. + +`ResourceCsp`, field by field (Python name, wire key, what the host does with it): + +| Python | Wire (`_meta.ui.csp`) | Controls | +|---|---|---| +| `connect_domains` | `connectDomains` | `connect-src`: where `fetch`/XHR may go | +| `resource_domains` | `resourceDomains` | `img-src`, `style-src`, ...: static assets | +| `frame_domains` | `frameDomains` | `frame-src`: nested iframes | +| `base_uri_domains` | `baseUriDomains` | `base-uri`: what `<base>` may point at | + +`ResourcePermissions`: each field requests a browser permission for the iframe. + +| Python | Wire (`_meta.ui.permissions`) | +|---|---| +| `camera` | `camera` | +| `microphone` | `microphone` | +| `geolocation` | `geolocation` | +| `clipboard_write` | `clipboardWrite` | + +!!! note + CSP and permissions live on the **resource**, never on the tool. The spec's tool + metadata has no slot for them, and hosts ignore them there. The SDK makes the + mistake unrepresentable: `@apps.tool()` simply has no `csp` parameter. + +### Visibility + +`visibility=["app"]` on a tool says "this exists for the iframe, not the model": + +* `"model"`: the model may call it. +* `"app"`: the iframe may call it (via `callServerTool`). +* Omitted: both, which is the default. + +Filtering is the **host's** job. Your server lists app-only tools in `tools/list` +like any other; the host hides them from the model. Don't filter server-side. + +## The rules the SDK enforces + +All of these fail at startup, not in production: + +* A `resource_uri` or resource URI that isn't `ui://...` is a `ValueError` at + decoration/registration time. +* A tool bound to a URI with **no matching registered resource** is a `ValueError` + when `MCPServer(extensions=[apps])` consumes the extension. A tool advertising + HTML that 404s on `resources/read` is a misconfiguration, so it refuses to + construct. +* `meta={"ui": ...}` on `@apps.tool()` is a `ValueError`. The decorator owns + `_meta["ui"]`; say it with `resource_uri=` and `visibility=`. Other `meta=` keys + merge fine alongside. + +Neither the TypeScript ext-apps SDK nor FastMCP catches any of these today; we'd +rather you find out before a host does. + +## Beyond inline HTML + +`add_html_resource` covers the common case: a string of HTML. For anything else, +HTML on disk or generated content, build the resource yourself and hand it over: + +```python title="server.py" hl_lines="12 18" +--8<-- "docs_src/apps/tutorial003.py" +``` + +`add_resource` fills in the `text/html;profile=mcp-app` MIME type when the resource +doesn't set one explicitly, and rejects an explicit mismatch: a `ui://` resource +under any other MIME type is one no host will render. + +!!! tip + Targeting a pre-GA host that still reads the deprecated flat + `_meta["ui/resourceUri"]` key? Merge it yourself: + `@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})`. + The nested `ui` object is the spec shape; the flat key is on its way out. + +## See it run + +The `apps` story in `examples/stories/` is this page as a runnable pair: a server +with a UI-bound clock tool and a client that negotiates Apps, reads the tool's +`_meta.ui.resourceUri`, fetches the HTML, and calls the tool. + +```bash +uv run python -m stories.apps.client +``` diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md new file mode 100644 index 0000000..0358ba5 --- /dev/null +++ b/docs/advanced/extensions.md @@ -0,0 +1,249 @@ +# Extensions + +An **extension** is an opt-in bundle of MCP behaviour behind one identifier. + +On a server it can contribute tools, resources, and new request methods, and it can wrap +`tools/call`. On a client it can claim extra `tools/call` result shapes and observe vendor +notifications. Each side advertises under its own `capabilities.extensions`, and nothing +changes for anyone who didn't ask for it. That is the contract ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)), and +it has one golden rule: **extensions are off by default**. + +## Using an extension + +Pass instances at construction: + +```python title="server.py" +--8<-- "docs_src/extensions/tutorial001.py" +``` + +Done. The server now advertises `io.modelcontextprotocol/ui` under +`capabilities.extensions` and serves everything the extension contributes. + +`Apps` is the built-in reference extension, and it gets its own page: **[MCP Apps](apps.md)**. + +!!! note + Extensions are fixed at construction. There is no `add_extension` to call later: + a server's capability map should not change while clients are connected to it. + +The capability map rides `server/discover`, which is a **2026-07-28** path. A legacy +`initialize` handshake has nowhere to put it, so a legacy client simply doesn't see +the extension. Design for that: an extension *augments* a server, it must not be the +only way the server is usable. + +## Writing your own + +Subclass `Extension` and override only what you need. Every method has a default. + +### The identifier + +```python +--8<-- "docs_src/extensions/tutorial002.py" +``` + +The identifier is a `vendor-prefix/name` string following the spec's `_meta` key +grammar: dot-separated labels (each starts with a letter, ends with a letter or +digit), a slash, then the name. It is validated **when the class is defined**, so a +typo doesn't wait for a server to boot: + +```text +TypeError: Stamps.identifier must be a `vendor-prefix/name` string +(reverse-DNS prefix required), got 'stamps' +``` + +Use a domain you control as the prefix. `io.modelcontextprotocol/*` is for extensions +specified by the MCP project itself. + +### Contributing tools + +The smallest useful extension is one tool and a settings map: + +```python title="server.py" hl_lines="17 19-20 22-23 26" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +* `tools()` returns `ToolBinding`s. The server registers each one exactly as if you + had called `mcp.add_tool(...)` yourself: same schema generation, same `Context` + injection, same everything. +* `settings()` is the value advertised at `capabilities.extensions["com.example/stamps"]`. + Return `{}` (the default) to advertise the extension with no settings. +* The extension never receives the server. It declares contributions as data; + `MCPServer` consumes them. There is no `self.server` to mutate. + +And `main()` is the proof, an in-memory client straight against `mcp`: + +```python title="server.py" hl_lines="29-34" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +### Serving your own methods + +An extension can register **new request methods**: its own verbs, served next to the +spec's: + +```python title="server.py" hl_lines="16-22 31 40-48" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `SearchParams` subclasses `RequestParams`, so the 2026 `_meta` envelope parses + uniformly and your handler gets validated params, never a raw dict. Bound what + the client controls: `Field(ge=1, le=100)` rejects an absurd `limit` before + your code allocates anything for it. +* `require_client_extension(ctx, EXTENSION_ID)` is the gate: a client that did not + declare the extension gets the `-32021` (missing required client capability) error, + with the machine-readable `requiredCapabilities` payload the spec asks for. +* `protocol_versions=frozenset({"2026-07-28"})` pins the method to one wire version. + At any other version the client gets `METHOD_NOT_FOUND`, exactly as if the method + didn't exist there. For that client, it doesn't. + +Methods are **strictly additive**. The SDK enforces this at construction, not at +runtime: + +* A `MethodBinding` for a spec-defined method (`tools/list`, `completion/complete`, ...) + raises `ValueError` when the binding is constructed. Core verbs belong to the server. +* Two extensions binding the same method raise when the second one registers. + Last-write-wins is how plugins corrupt each other; we don't do that. +* An empty `protocol_versions` set raises too: a method that can never be served + is a bug, not a configuration. + +### The client side + +The same file's `main()` is the whole client story, both halves of it: + +```python title="server.py" hl_lines="54-58" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `Client(..., extensions=[advertise(EXTENSION_ID)])` declares the extension. The + declarations become `ClientCapabilities.extensions`: on a 2026-07-28 connection + the map travels in the per-request `_meta` envelope, so the server sees it on + **every** request; on a legacy connection it rides the `initialize` handshake. + Server code doesn't care which: `require_client_extension(ctx, ...)` and + `ctx.session.check_client_capability(...)` read the right source on both paths. +* Vendor methods drop one layer to `client.session.send_request(...)`; `Client` + only grows first-class methods for spec verbs. `send_request` accepts any + `Request` subclass, so the vendor request passes as-is. + +### Intercepting `tools/call` + +The one interceptive hook. Override `intercept_tool_call` to observe, short-circuit, +or veto a tool call: + +```python title="server.py" hl_lines="18-25" +--8<-- "docs_src/extensions/tutorial005.py" +``` + +* `params` is the validated `CallToolRequestParams`: you get `params.name` and + `params.arguments` without touching raw JSON. +* `call_next(ctx)` runs the rest of the chain. Return its result unchanged (observe), + return something else (replace), or raise an `MCPError` (refuse). +* With several extensions, interceptors nest in registration order: the first + extension in `extensions=[...]` is outermost. +* The default implementation is a pass-through, and a server whose extensions never + override this hook installs **no** middleware at all. You don't pay for what + you don't use. + +The hook wraps `tools/call` and nothing else. For every-message concerns, use +[Middleware](middleware.md). That is what it is for. + +## Using a client extension + +A **client extension** is the same contract from the consuming side: a bundle of +client-side behaviour behind one identifier. Pass instances to +`Client(extensions=[...])` and call tools normally: + +```python title="client.py" hl_lines="67-69" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +`call_tool("buy", ...)` returns a plain `CallToolResult`, like every other call. What +the extension changed: the server may now answer `buy` with a `receipt` **result +shape** instead of a final result, and `Receipts` finishes it (here by redeeming the +receipt with a follow-up call) before `call_tool` returns. Nothing about the call +site moves. + +Drop the extension and none of this exists: the server's gate refuses a client +that did not declare it (error -32021), and a claimed shape from a server that +skips the gate fails validation, exactly as the spec requires for an +unrecognized `resultType`. Off by default, on both ends of the wire. + +To advertise an identifier with **no** client-side behaviour (the server gates on +the capability, the client does nothing, as in the search client above), use +`advertise()`: + +```python +from mcp.client import advertise + +client = Client(mcp, extensions=[advertise("com.example/search")]) +``` + +## Writing a client extension + +Subclass `ClientExtension` and override only what you need. Three contribution +kinds, each with a default: `settings()`, `claims()`, and `notifications()`. + +```python title="client.py" hl_lines="18-19 44-45 47-48" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +* The identifier follows the same grammar as the server's, validated when the class + is defined. +* `claims()` returns `ResultClaim`s: a wire tag, the model that parses it, and the + resolver that finishes it. The model must pin the tag with + `result_type: Literal["receipt"]` and must not subclass the verb's core result + types; both are enforced when the claim is constructed. Vendor fields like + `receipt_token` ride the wire as-is: a substituted shape reaches the client + verbatim. +* The resolver receives the parsed model and a `ClaimContext`; `ctx.session` is the + same public handle as `client.session`, so follow-ups are ordinary session calls. + It returns the verb's normal `CallToolResult`. +* `settings()` is the value advertised at `ClientCapabilities.extensions[identifier]`, + read once at `Client` construction. + +`notifications()` declares vendor server notifications to observe: + +```python +def notifications(self) -> Sequence[NotificationBinding[Any]]: + return [NotificationBinding(method="notifications/receipts", params_type=ReceiptEvent, handler=self.on_receipt)] +``` + +The handler receives validated params one at a time, in dispatch order. It observes; it cannot veto +or reply. + +Two quiet rules. Claims are active on 2026-07-28 connections only, and the capability +ad follows them: on a legacy connection the claims dissolve and the identifier drops +out of the ad with them, so the client never advertises an extension whose shapes it +would reject. And when you want the claimed shape yourself instead of the resolver, +call `client.session.call_tool(..., allow_claimed=True)`; without that flag, a +claimed shape reaching a session-tier caller raises `UnexpectedClaimedResult`. + +### Extension verbs + +An extension's own request methods need no client-side registration. A vendor request +type subclasses `mcp_types.Request` and goes through `client.session.send_request`, +as in [Serving your own methods](#serving-your-own-methods). One addition: when a +params key must ride the `Mcp-Name` header (extension specs such as tasks require +this for their verbs), the request type declares `name_param`: + +```python title="client.py" hl_lines="23-26 47-48" +--8<-- "docs_src/extensions/tutorial007.py" +``` + +The session mirrors `params["jobId"]` into `Mcp-Name` on every send path, and a +missing value fails loudly rather than silently omitting a required header. + +## What an extension cannot do + +The contribution surface is **closed** on purpose. On the server: settings, tools, +resources, methods, one `tools/call` interceptor. On the client: settings, result +claims, notification bindings. An extension cannot: + +* **Reach into the host.** It declares data; it holds no server or client reference. +* **Replace core behaviour.** Spec methods and core result tags are rejected at + construction (`initialize` is reserved by the runner outright); a notification + binding shadowed by core vocabulary goes quiet with a warning instead. +* **Register late.** After `MCPServer(...)` or `Client(...)` returns, the extension + set is what it is. + +If you are fighting these walls, you are not writing an extension. You are writing +a fork. The walls are the feature: a user reading `extensions=[Apps(), Stamps()]` +knows *everything* those two can have touched. diff --git a/docs/advanced/index.md b/docs/advanced/index.md new file mode 100644 index 0000000..92af6d1 --- /dev/null +++ b/docs/advanced/index.md @@ -0,0 +1,29 @@ +# Advanced + +Everything an ordinary server or client needs has a topical home in the sections above. +This section is the escape hatches you reach for when `MCPServer`'s convenience +layer is in the way: + +* **[The low-level Server](low-level-server.md)**: the class `MCPServer` is built on. + Hand-written schemas, `on_*` handlers, nothing checked for you, and custom JSON-RPC + methods of your own. +* **[Pagination](pagination.md)** and **[Middleware](middleware.md)**: two things you + can *only* do on the low-level `Server`. +* **[Extensions](extensions.md)** and **[MCP Apps](apps.md)**: the protocol's + extension surface. Compose extension packages into a server, or write your own. + +A few things you might reasonably look for here live where you'd actually use them +instead: + +* **Authorization** is under **[Running your server](../run/index.md)** because you + protect a server where you deploy it. +* **OAuth**, **identity assertion**, connecting to **multiple servers**, and the + response **cache** are all under **[Clients](../client/index.md)**. +* **Multi-round-trip requests** and **Subscriptions** are under + **[Inside your handler](../handlers/index.md)** because both are things a + handler *does*. +* **URI templates** is under **[Servers](../servers/index.md)**, next to Resources. +* **[Protocol versions](../protocol-versions.md)** and + **[Deprecated features](../deprecated.md)** each have their own top-level page. + +If you're not sure whether you need this section, you don't. diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md new file mode 100644 index 0000000..26df8f6 --- /dev/null +++ b/docs/advanced/low-level-server.md @@ -0,0 +1,199 @@ +# The low-level Server + +`@mcp.tool()` is a layer. Underneath it is a second server class, `Server`, that speaks raw MCP: you hand it the protocol objects and it puts them on the wire, unchanged. + +`MCPServer` is built on top of it. You drop down when the convenience layer is in the way: + +* You need to emit an **exact** schema (loaded from a file, generated from a database), not one derived from a Python signature. +* You need full control of the result: `_meta`, `is_error`, every key of `structured_content`. +* You need to handle a method MCP doesn't define. + +For everything else, stay on `MCPServer`. + +## The same tool, by hand + +This is the `search_books` tool that **[Tools](../servers/tools.md)** writes in nine lines of `@mcp.tool()`, with the sugar removed: + +```python title="server.py" hl_lines="23 27 33" +--8<-- "docs_src/lowlevel/tutorial001.py" +``` + +Three things changed, and they are the whole low-level API: + +* **Handlers are constructor parameters.** `on_list_tools=` and `on_call_tool=` go into `Server(...)`. There are no decorators down here, and every handler has the same shape: `async (ctx, params) -> result`. +* **You write the input schema.** `Tool.input_schema` is a plain JSON Schema `dict`. Nobody derives it from type hints, because there are no type hints to derive it from. +* **You build the result.** `CallToolResult(content=[TextContent(...)])`, by hand. Nothing is wrapped, converted, or inferred from a return annotation. + +`params` is the parsed request: `CallToolRequestParams` gives you `.name` and `.arguments`. `ctx` is a `ServerRequestContext`: `ctx.session` for talking back to the client, `ctx.lifespan_context`, `ctx.request_id`, and `ctx.meta`, the request's inbound `_meta`. + +!!! info + If you've used FastAPI, you already know this relationship. `MCPServer` is the decorators-and-type-hints layer; `Server` is the Starlette underneath. They are not rivals: `MCPServer` constructs a `Server` and registers handlers exactly like these on it. + +### Try it + +There is no Inspector for this one: `mcp dev` and `mcp run` only accept an `MCPServer`. The in-memory `Client` doesn't care; it takes a low-level `Server` exactly like it takes an `MCPServer`: + +```python title="main.py" +import asyncio + +from mcp import Client + +from server import server + + +async def main() -> None: + async with Client(server) as client: + result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) + print(result.content) + + +asyncio.run(main()) +``` + +```text +[TextContent(type='text', text="Found 3 books matching 'dune' (showing up to 5).", annotations=None, meta=None)] +``` + +The same text the `@mcp.tool()` version produced. Two honest differences: + +* `result.structured_content` is `None`. The high-level server wraps a `-> str` into `{"result": ...}` for you; here nobody builds what you didn't build. +* `list_tools` returns the schema **you** typed, character for character. The high-level version had `"title": "Query"` on every property and a `"title": "search_booksArguments"` at the root: Pydantic artifacts. Down here, if it's on the wire, you put it there. + +## Nothing is checked for you + +`MCPServer` rejects a bad argument before your function ever runs, validating the call against the schema it generated (**[Tools](../servers/tools.md)**). + +`Server` does not do that. Your `input_schema` is *advertised* to the client; it is never *applied* to `params.arguments`. + +!!! check + Call `search_books` without `limit` and your `args["limit"]` raises `KeyError`. The client sees: + + ```text + MCPError: Internal server error + ``` + + A JSON-RPC error, code `-32603`, with a deliberately generic message: the SDK won't leak your traceback to a remote caller. The model never finds out what it did wrong, so it can't retry. (In a test, `raise_exceptions=True` surfaces the real exception instead; see **[Testing](../get-started/testing.md)**.) + +That generalises. An exception raised from a low-level handler is **always** a protocol error, never an `is_error=True` tool result. If you want the model to read the failure and recover, validate `params.arguments` yourself and return `CallToolResult(content=[TextContent(...)], is_error=True)`. The two kinds of failure are the subject of **[Handling errors](../servers/handling-errors.md)**. + +## Two tools, one handler + +`on_call_tool` is the single entry point for every tool on the server. You route on `params.name`: + +```python title="server.py" hl_lines="39-44" +--8<-- "docs_src/lowlevel/tutorial002.py" +``` + +* `list_tools` advertises both. `call_tool` dispatches on the name. +* The `else` branch matters: `Server` will happily forward a `tools/call` for a name you never listed straight into your handler. Raising there turns the call into the same `-32603` as above. + +## Structured output, by hand + +Declare `output_schema` on the `Tool` and put `structured_content` on the result. Both are yours: + +```python title="server.py" hl_lines="20-24 37" +--8<-- "docs_src/lowlevel/tutorial003.py" +``` + +Call it and the result carries both representations: + +```json +{ + "content": [{"type": "text", "text": "Found 3 books matching 'dune'."}], + "structuredContent": {"matches": 3, "query": "dune"}, + "isError": false, + "resultType": "complete" +} +``` + +The server never compares the two fields. This SDK's `Client` does: return `structured_content` that doesn't satisfy the `output_schema` you declared and `call_tool` raises a `RuntimeError` that starts with `Invalid structured content returned by tool search_books` and goes on to quote the `jsonschema` failure. Promising a schema is cheap; keeping it is on you. The whole ladder of return types and schemas is in **[Structured Output](../servers/structured-output.md)**. + +## `_meta`: for the application, not the model + +`content` is the part of the answer the model reads. `structured_content` is the same answer as typed data. `_meta` is the third channel: data that rides along with the result for the **client application**, without being part of the answer at all. + +Use it for record IDs, trace IDs, anything your UI needs and your prompt doesn't: + +```python title="server.py" hl_lines="38" +--8<-- "docs_src/lowlevel/tutorial004.py" +``` + +* You construct it as `_meta=`, the wire name. The client reads it back as `result.meta`. +* Namespace your keys (`bookshop/record_ids`). The `io.modelcontextprotocol/*` keys are reserved by the protocol. + +!!! warning + `_meta` is a convention between you and the client application, not a guarantee about what reaches + the model. The host decides what it renders. Never put a secret in any part of a tool result. + +## Capabilities follow your handlers + +A `Server` advertises exactly the method families you gave it handlers for. The `Bookshop` above passes `on_list_tools` and `on_call_tool` and nothing else, so a client connecting to it sees: + +```json +{"tools": {"listChanged": false}} +``` + +No `resources`, no `prompts`: there is nothing to back them. Pass `on_list_prompts` and `prompts` appears; pass `on_completion` and `completions` appears. + +`MCPServer` always advertises tools, resources and prompts, whether you registered any or not, because its managers always exist. Down here the declaration *is* the constructor call. + +## The lifespan generic + +`Server` is generic in the type its lifespan yields. Annotate it once and the object is typed everywhere it surfaces: + +```python title="server.py" hl_lines="25-27 45-46 51" +--8<-- "docs_src/lowlevel/tutorial005.py" +``` + +* The lifespan is a `Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]]`; `@asynccontextmanager` on an `async` generator gives you exactly that. +* Whatever it `yield`s becomes `ctx.lifespan_context`, and because the handlers are annotated `ServerRequestContext[Catalog]`, `.search(...)` autocompletes and type-checks. +* It is entered once when the server starts and exited once when it stops. Startup, teardown, and `MCPServer`'s version of the same idea are in **[Lifespan](../handlers/lifespan.md)**. + +Without a `lifespan=`, `ctx.lifespan_context` is an empty `dict`. + +## A method of your own + +The constructor covers the methods MCP defines. `add_request_handler` covers everything else: + +```python title="server.py" hl_lines="35-36 39-40 43-44 48" +--8<-- "docs_src/lowlevel/tutorial006.py" +``` + +* The first argument is the method string. Notifications have a twin, `add_notification_handler`. +* `params_type` is the model the incoming `params` are validated against **before** your handler runs, so custom methods *do* get the validation tools don't. Subclass `RequestParams` so the `_meta` field parses like every other method's. +* The handler returns a `BaseModel`, a `dict`, or `None`. The SDK serialises it into the JSON-RPC result. + +One honest caveat: the high-level `Client` only has verbs for the methods MCP defines, so there is no `client.reindex()`. A vendor method is for a peer that already knows it exists: a client you also ship, or another service of yours speaking JSON-RPC. + +One method you cannot claim: + +```text +ValueError: 'initialize' is handled by the server runner and cannot be overridden; +use Server.middleware to observe or wrap initialization +``` + +The handshake belongs to the runner. `server/discover`, `ping`, and every other built-in are yours to replace. + +!!! tip + `Server.middleware`, mentioned in that error, wraps **every** inbound message, including `initialize`. If what you want is to observe or rewrite traffic rather than answer a new method, start at **[Middleware](middleware.md)**. + +## The other handlers + +Each of these is one idea you now have the vocabulary for; each has its own page. + +* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](../handlers/multi-round-trip.md)**. True to this tier, nothing is installed for you: where `MCPServer` seals `requestState` by default, here the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`: one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` performs (**[Protecting `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**). +* `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` are the same `(ctx, params) -> result` shape for the other primitives. +* `on_subscriptions_listen` serves the 2026-07-28 `subscriptions/listen` stream. Pass a `ListenHandler` built over a `SubscriptionBus` and publish events to the bus from your other handlers; see **[Subscriptions](../handlers/subscriptions.md)** for the full composition. +* `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **[Running your server](../run/index.md)** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story. + +## Recap + +* The low-level `Server` takes its handlers as `on_*` **constructor parameters**; every handler is `async (ctx, params) -> result`. +* You write the `input_schema` dict and you build the `CallToolResult`. Nothing is derived, wrapped, or validated for you. +* An exception in a handler is a `-32603` protocol error. A tool error the model can read is a `CallToolResult` with `is_error=True` that **you** return. +* `_meta` on the result is addressed to the client application, not the model. +* `Server[T]` is generic in what its lifespan yields; `ctx.lifespan_context` is a typed `T`. +* `add_request_handler(method, params_type, handler)` serves any method. `initialize` is reserved. +* The capabilities a `Server` advertises are derived from which handlers you registered. + +`Client(server)` treated both servers identically because they *are* the same protocol, which is the whole point. The next layer down isn't a class at all: it's **[Middleware](middleware.md)**. diff --git a/docs/advanced/middleware.md b/docs/advanced/middleware.md new file mode 100644 index 0000000..4e57bae --- /dev/null +++ b/docs/advanced/middleware.md @@ -0,0 +1,108 @@ +# Middleware + +A **middleware** is one async function that wraps every message your server receives. + +You write it as `async (ctx, call_next)` and append it to `server.middleware`. That is the whole API. + +!!! warning + `Server.middleware` is marked **provisional** in the source. The signature and semantics are + expected to change before v2 is final. Use it to *observe*: timing, logging, tracing. + Do not make it the foundation your server stands on. + +This is a **low-level `Server`** feature. `MCPServer` does not expose a middleware list. +If `Server(name, on_call_tool=...)` is new to you, read **[The low-level Server](low-level-server.md)** first. + +## A timing middleware + +One server, one tool, one middleware that logs how long each message took: + +```python title="server.py" hl_lines="40-46 50" +--8<-- "docs_src/middleware/tutorial001.py" +``` + +* `ctx` is the same `ServerRequestContext` your handlers receive. `ctx.method` is the raw + method string; `ctx.params` are the raw params, **before** any validation. +* `call_next(ctx)` runs the rest of the chain: validation, the handler lookup, your handler. + Return what it returned and the response is untouched. +* The `try`/`finally` is deliberate: a handler that raises is still timed, because the failure + reaches your middleware as the exception out of `call_next`. +* `server.middleware.append(...)` registers it. The list runs outermost-first, so + `middleware[0]` is the one closest to the wire. + +### Try it + +Connect a client, list the tools, call one. Your log has **three** lines: + +```text +server/discover took 18.3 ms +tools/list took 0.1 ms +tools/call took 0.1 ms +``` + +You made two calls and got three lines. The first is `server/discover`: the request the +client sent to set the connection up, before you asked for anything. + +That is the point. Middleware wraps **every** inbound message: + +* The connection setup: `server/discover`, or `initialize` and `notifications/initialized` + on a legacy session. +* Every request and every notification. For a notification, `ctx.request_id is None`, + `call_next(ctx)` returns `None`, and whatever you return is discarded. +* Even a method the server has no handler for: `call_next` raises the + `MCPError(-32601, "Method not found")` *through* your middleware on its way to the client. + +## What you can do inside one + +In increasing order of how much you should hesitate: + +* **Observe.** Time it, count it, log it. The example above. +* **Refuse.** Raise an `MCPError` *instead of* calling `call_next(ctx)` and that one message is + answered with a JSON-RPC error. The connection stays up; the next message goes through. +* **Rewrite.** `ctx` is a dataclass: `await call_next(dataclasses.replace(ctx, params=...))` + hands the rest of the chain different params than the client sent. Never do this to + `initialize`: the result the client gets back is built from your rewritten params, but the + server commits its connection state from the original wire params. The two sides can finish + the handshake disagreeing about what they negotiated. + +!!! check + `initialize` is one of the things middleware wraps, and it is the *only* hook you get + for it. Try to take it over with `add_request_handler` and the SDK refuses: + + ```text + ValueError: 'initialize' is handled by the server runner and cannot be overridden; + use Server.middleware to observe or wrap initialization + ``` + +!!! warning + `initialize` is handled inline: the server reads no further inbound messages until your + middleware chain returns. Awaiting a server-to-client request (`ctx.session.send_request(...)`, + an elicitation) while handling `initialize` therefore **deadlocks the connection**: the + response you are waiting for can never be read. Fire-and-forget notifications are fine. + +## The one middleware that ships on by default + +The SDK ships exactly one middleware, and it is already on your server's list: the one that +emits an OpenTelemetry span for every message. You don't append it, and most of the time you +don't think about it. It is a no-op until you install an exporter, and it has its own page: +**[OpenTelemetry](../run/opentelemetry.md)**. + +!!! info + If you have written ASGI middleware, you already know this shape. Starlette's + `(scope, receive, send)` became `(ctx, call_next)`, and it runs *after* the transport, on + the decoded message instead of the raw HTTP request. The two compose: Starlette middleware + on `streamable_http_app()` sees HTTP; this sees MCP. + +## Recap + +* A middleware is `async (ctx, call_next) -> result`, appended to `server.middleware` on the + low-level `Server`. +* It wraps **every** inbound message (`server/discover`, `initialize`, requests, notifications, + unknown methods) and runs outermost-first. +* `ctx.request_id is None` is how you tell a notification from a request. +* Raise instead of calling `call_next` to refuse one message; the connection survives. +* The SDK's own OpenTelemetry tracing is a middleware too, already on the list. See + **[OpenTelemetry](../run/opentelemetry.md)**. +* The whole surface is provisional. Observe with it; don't build on it. + +That is everything that wraps a request. **[Authorization](../run/authorization.md)** is what decides whether the request +gets to run at all. diff --git a/docs/advanced/pagination.md b/docs/advanced/pagination.md new file mode 100644 index 0000000..381f7fa --- /dev/null +++ b/docs/advanced/pagination.md @@ -0,0 +1,80 @@ +# Pagination + +Most servers never need this. + +`MCPServer` answers every `list_*` request with everything it has, in one page, `next_cursor=None`. For a few dozen tools, resources or prompts that is the right answer and there is nothing to configure. + +Pagination is for the server whose resource list is really a database: thousands of rows it refuses to serialize in one response. The protocol's answer is a **cursor**: the server returns a page plus an opaque token, and the client sends that token back to get the next page. + +`@mcp.resource()` has no hook for any of that. To page, you write the list handler yourself, on the **[low-level Server](low-level-server.md)**. + +## A server that pages + +```python title="server.py" hl_lines="13 16-17" +--8<-- "docs_src/pagination/tutorial001.py" +``` + +* On a low-level `Server`, handlers are constructor arguments, not decorators. `on_list_resources` answers every `resources/list` request; that's the whole hookup. +* Every paged handler is typed `params: PaginatedRequestParams | None`, and the example accepts both. Over a connection, though, the SDK never hands you `None` (a request with no `params` member reaches the handler as the model with its defaults), so the signal that matters is `params.cursor is None`: **start from the top**. +* You decide what a cursor *is*. Here it's an offset rendered as a string. A timestamp, a primary key, a base64 blob: anything you can mint on the way out and recognise on the way back in. +* `next_cursor=None` is how you say "that was the last page". There is no count, no total, no `has_more`. `None` is the entire signal. + +!!! tip + A `PAGE_SIZE` of 10 makes the example readable. Pick yours per endpoint: a list of + one-line resources can afford a page of 500; a list of fat prompt templates cannot. + The client has no say in it, and that is by design. + +### Try it + +`Client(server)` connects to a low-level `Server` in memory exactly as it connects to an `MCPServer`. + +Call `list_resources()` with no arguments. You get ten resources, `book-1` through `book-10`, and `next_cursor` is the string `"10"`. + +Hand it back with `list_resources(cursor="10")` and the first resource is `book-11`, the new `next_cursor` is `"20"`. + +The tenth page comes back with `next_cursor` set to `None`. Done. + +## The client loop + +Every `list_*` method on `Client` (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) takes a `cursor=` keyword. Draining a paged list is one `while True`: + +```python title="client.py" hl_lines="27-33" +--8<-- "docs_src/pagination/tutorial002.py" +``` + +* `cursor` starts as `None`, so the first request carries no cursor. +* Extend **before** you look at `next_cursor`: the last page has resources too. +* `next_cursor is None` is the exit. Anything else goes straight back into `cursor=`, untouched. + +Run its `main()` and it prints `100 resources`: ten pages of ten, stitched together by a loop that never knew there were ten pages. + +This is the same loop **[The Client](../client/index.md)** shows for every `list_*` verb, and it costs nothing against a server that doesn't page: `next_cursor` is `None` on the first response and the loop runs once. + +## The three rules + +**Cursors are opaque.** A client must never parse, build, or guess one. The only legal source of a cursor is the previous page's `next_cursor`, verbatim. + +**The server picks the page size.** There is no `limit=` in the protocol. If you need a different page size, you change the server. + +**A client that ignores paging still works.** It calls `list_resources()` once, gets the first ten, and never notices the `next_cursor` it threw away. Nothing breaks; it sees less. + +!!! check + Opaque means opaque. Invent a cursor (`list_resources(cursor="page-2")`) and there is + nothing the protocol can do for you. This server tries `int("page-2")`, the handler raises, + and what comes back to the client is: + + ```text + MCPError(-32603, 'Internal server error', None) + ``` + + A cursor you didn't get from the server is a bug, not a feature request. + +## Recap + +* `MCPServer` returns everything in one page. Pagination is opt-in, and you opt in on the low-level `Server`. +* `on_list_resources` (and `on_list_tools`, `on_list_prompts`, `on_list_resource_templates`) receives `PaginatedRequestParams | None`; `params.cursor` is `None` for the first page. +* You return a page plus `next_cursor`: any string you'll recognise later, or `None` when there is nothing left. +* The client loop: pass `cursor=`, accumulate, repeat until `next_cursor is None`. +* Cursors are opaque, the server owns the page size, and a non-paging client still gets page one. + +The rest of the hand-written `Server` API (`on_call_tool`, `input_schema` dicts, `_meta`) is **[The low-level Server](low-level-server.md)**. diff --git a/docs/client/caching.md b/docs/client/caching.md new file mode 100644 index 0000000..dc4ae97 --- /dev/null +++ b/docs/client/caching.md @@ -0,0 +1,117 @@ +# Caching hints + +Every result a server returns for `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read` and `server/discover` carries two fields on the 2026-07-28 protocol: `ttlMs`, how many milliseconds a client may treat the result as fresh, and `cacheScope`, whether a cached result may be shared across users (`"public"`) or belongs to one authorization context (`"private"`). + +The server doesn't cache anything. The fields are a *declaration*: "this tool list is the same for everyone and won't change for a minute." A client (or a gateway in front of you) may then skip the round trip. Honoring the hints is the client's choice; emitting them is the server's job, and the SDK does it for you. + +Out of the box every result says `ttlMs: 0, cacheScope: "private"`: immediately stale, never shared. That is always safe and always conformant. If your lists really are stable and identical for all callers, say so at construction: + +```python title="server.py" hl_lines="5-8" +--8<-- "docs_src/caching/tutorial001.py" +``` + +* The map is keyed by **method name**, and the six cacheable methods are the only legal keys. The parameter is typed `Mapping[CacheableMethod, CacheHint]`, so your editor autocompletes the keys and flags a typo before you run; anything that slips past the type checker raises at construction. +* A method you don't mention keeps the defaults. The map is a set of overrides, not a manifest. +* `CacheHint(ttl_ms=5_000)` left `scope` unset, so it stays `"private"`: five seconds of freshness, per caller. Scope and TTL are independent decisions. +* `"server/discover"` is a legal key too, since the discovery result is cacheable like any list. + +!!! warning + `cacheScope: "public"` means *anyone* may be served your cached response. A shared + gateway will happily hand one user's result to another, even when the request was + authenticated. Mark a result `"public"` only when it is identical for every caller, and + never use `cacheScope` as access control: it is a label, not a lock. + +## Per-handler override + +On the low-level `Server`, handlers build their results by hand, and `ttl_ms` / `cache_scope` are just fields on the result models. A handler that sets them explicitly always wins over the constructor map, field by field: + +```python title="server.py" hl_lines="11 17" +--8<-- "docs_src/caching/tutorial002.py" +``` + +The handler said `ttl_ms=1_000` and nothing about scope. On the wire: `ttlMs: 1000` (the handler's, not the map's `60_000`) and `cacheScope: "public"` (the map's, because the handler left it unset). Explicit beats configured, and configured beats default. This holds per field, so a handler can pin one field and leave the other to the server-wide policy. + +This is also the escape hatch for dynamics the constructor can't know: a handler that filters `resources/read` per user can return `cache_scope="private"` for one URI from an otherwise-public server. + +One caveat on paginated lists: the protocol requires the **same `cacheScope` on every page** of one list. The constructor map satisfies that by construction, since it's keyed by method, not by page. But a handler that overrides the scope itself owns that consistency: override it on *every* page, never only when a cursor is present, or page one and page two will disagree. + +## What the client sees + +On a 2026-07-28 session, `Client` honors the hints for you: it has a built-in response cache, on by default. A result that arrives carrying a `ttlMs` is stored, and an identical call within that TTL is served from the cache with no round trip. A result that carries *no* hint is not cached: hint-less results get `CacheConfig.default_ttl_ms`, which defaults to `0` (immediately stale), so a server that declares nothing sees exactly the call-for-call traffic it always did. + +```python title="client.py" hl_lines="34 36 39" +--8<-- "docs_src/caching/tutorial003.py" +``` + +Four calls, three fetches. The second call found a fresh entry and never reached the server; advancing the (injected) clock past the TTL made the third fetch again; the fourth said `cache_mode="refresh"`. That kwarg exists on the five caching verbs (`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`): + +* `"use"` (the default) serves a fresh entry if there is one, and stores the fetch if not. +* `"refresh"` never serves: it fetches and stores the result, replacing whatever was cached. +* `"bypass"` makes the round trip without touching the cache at all: no read, no write. + +One rule sits above `"use"`: **calls carrying `meta` always reach the server.** A request with `meta` set (a progress token, tracing fields) expects a wire request, so under `cache_mode="use"` it is treated as `"refresh"`: the cache read is skipped, and the fetched result still replaces the cached entry. `"bypass"` and an explicit `"refresh"` behave as they always do. + +To turn caching off entirely, construct with `Client(server, cache=False)`: every call is a round trip again, and `cache_mode`, while still accepted, does nothing. + +Scope is honored automatically too: `"private"` entries are keyed to the cache's *partition* (below), while `"public"` ones may opt into wider sharing. And **notifications beat TTL** for the exact entries they name: a `list_changed` notification evicts the matching cached listing, and `resources/updated` evicts the cached read stored under exactly its URI, however fresh they were. On a 2026-07-28 connection those notifications arrive on a `subscriptions/listen` stream you open with `client.listen(...)`, and eviction completes before your watcher sees the event; **[Subscriptions](subscriptions.md)** is that page. + +One caveat on `resources/updated`: eviction is exact-URI only. The store contract has no enumerate or scan operation (same as the reference TypeScript implementation), so a notification carrying a *sub*-resource URI does not evict a cached read of its parent. If your server signals sub-resources this way, refetch the parent with `cache_mode="refresh"`. + +### Configuring it: `CacheConfig` + +```python +from mcp.client import CacheConfig + +client = Client("https://api.example.com/mcp", cache=CacheConfig(default_ttl_ms=5_000)) +``` + +* `store`: where entries live. The default is a fresh in-memory store per client; pass your own `ResponseCacheStore` implementation (Redis-backed, say) to share a cache across clients or processes. The contract types (`ResponseCacheStore`, `CacheKey`, `CacheEntry`, and the default `InMemoryResponseCacheStore`) are importable from `mcp.client`. A lookup may issue up to two sequential store `get`s (the private arm, then the public one), so size a remote store's latency expectations accordingly. A custom store **requires** an explicit `partition`. +* `partition`: the authorization-context label that keeps one principal's `"private"` entries from being served to another within a shared store. +* `target_id`: explicit server identity, for custom transports and in-process servers (below). +* `default_ttl_ms`: TTL applied to results that carry no `ttlMs` hint. The default `0` leaves hint-less results uncached. +* `share_public`: serve server-asserted-`"public"` entries across partitions (below). Off by default. +* `clock`: the wall-clock source, in epoch seconds. Inject one, as the example above does, and expiry tests need no sleeping. + +!!! warning "Partition = verified principal" + Derive `partition` from a **verified credential**, such as a validated token's subject. Never derive it from request-supplied data, and never from the server URL (server identity is a separate key axis). The SDK is a library with no authentication of its own: the trust anchor is whoever constructs the `CacheConfig`, which is the deployment, not the tenant. A multi-tenant gateway mints one `CacheConfig` per authenticated principal. + + The partition is also fixed for the `Client`'s lifetime. If the connection's authorization context changes mid-session (a re-authentication as a different principal, say), the cache does not follow; construct a new `Client` for the new principal. + +Cache keys also carry the **server's identity**: the URL string you dialed, with any `user:pass@` userinfo stripped and otherwise byte-exact. No case folding, no query reordering, no trailing-slash cleanup. Under-normalizing only costs sharing, while over-normalizing could merge two tenants (`?tenant=a` vs `?tenant=b`), so superficially different URLs simply don't share entries. When there is no URL (an in-process server, or a `Transport` instance), the client gets a random per-instance identity instead; set `CacheConfig.target_id` to name the server (with a custom store this is required, and construction says so). The identity is sha256-hashed before it enters key material, so a URL carrying secrets in its query string never appears in store keys. Don't log the pre-hash form yourself, either. + +!!! warning "`share_public` trusts the server, fleet-wide" + By default even `"public"` entries stay within their partition. `share_public=True` serves entries the server marked `cacheScope: "public"` to **every** partition using the store, trusting the server's classification on behalf of all of them. A server that stamps `"public"` on per-tenant data (by bug or by malice) then leaks one tenant's response to the others. The flag is deliberately constructor-level only: the per-call `cache_mode` can narrow caching, but nothing per-call can widen sharing. + +### What the cache never does + +* **Session-tier calls bypass it.** `client.session.list_tools()` and friends always make the round trip; the cache lives on the `Client` verbs. +* **`server/discover` stays out of it.** The discover result is delivered once, at connect, and never enters the response cache, even when it carries a `ttlMs`. If you persist one yourself to skip the reconnect probe ([`prior_discover`](../protocol-versions.md#reconnecting-with-prior_discover)), its freshness is your bookkeeping: `DiscoverResult` carries `ttl_ms` and `cache_scope`, already parsed, for exactly that purpose. +* **Continuation pages are never cached.** Only cursor-less calls participate. A continuation page rejected for an expired cursor does *evict* the cached listing, because the listing changed under it. +* **Multi-round-trip reads are never cached.** A `read_resource` seeded with `input_responses`/`request_state`, or one that resolves through input rounds, never enters the cache (a spec MUST). +* **Notification eviction needs notifications.** Eviction is only as good as the transport's delivery, and the modern in-process path (`Client(server)` with the default `mode="auto"`) does not deliver standalone notifications today. +* **Eviction is eventual, not instantaneous.** Wire-path notifications are dispatched from spawned tasks, so a call racing a notification's arrival may be served the pre-eviction entry once more; the window is bounded by dispatch latency, and the eviction still lands. +* **No stale-if-error.** An expired entry is never served because the refetch failed; the error propagates. +* **No early re-fetch.** A stored entry is served until its TTL expires and the next call after that pays the round trip; nothing refreshes in the background. +* **No coalescing.** Two concurrent identical calls are two fetches. +* **No TTL beyond 24 hours.** A larger `ttlMs`, whether server-sent or configured, is clamped down on store (`mcp.client.caching.MAX_TTL_MS`), bounding how long any entry, however generously hinted, can be served. +* On a **shared store**, clients race each other. Each client drops its own write when an eviction overtook the fetch in flight, but a *co-tenant* client can still write back an entry that an eviction it never saw had removed; and that race bookkeeping is itself bounded: past 4096 tracked keys the oldest key's guard is dropped first. Both windows are accepted, and closed by the TTL cap above. +* **No serving across protocol eras.** Entries are scoped to the negotiated protocol version: on a shared persistent store, a session never serves an entry written under a different negotiated version (the same listing genuinely differs by era, since the SDK strips the 2026 fields for older sessions). Eviction likewise touches only the current era's entries; another era's entries simply age out by TTL. + +### Reading the hints yourself + +The hints are also plain fields on every cacheable result (`result.ttl_ms` and `result.cache_scope`, already parsed), in case you want to layer your own bookkeeping on top of (or instead of) the built-in cache. + +Against an **older server** (pre-2026 protocol), the fields are simply absent from the wire, and the models show their conservative defaults: `ttl_ms == 0` and `cache_scope == "private"`, stale and unshared, the right assumption for a server that declared nothing. The cache treats a legacy session the same way: hints are never consulted there (whatever keys appear on the wire), only `default_ttl_ms` applies, and its default of `0` caches nothing, so a pre-2026 connection behaves exactly as it did before the cache existed. If you need to distinguish "the server said 0" from "the server said nothing", check `"ttl_ms" in result.model_fields_set`: it's only set when the field actually arrived. + +## Older clients + +Clients on pre-2026 protocol versions never see either field; the SDK strips them at serialization for those connections. Configure your hints once; there is nothing version-specific to write. + +## Recap + +* Six methods carry `ttlMs`/`cacheScope`; the SDK defaults them to `0`/`"private"`, stale and unshared, always safe. +* `cache_hints={method: CacheHint(...)}` at construction (both `MCPServer` and `Server`) sets server-wide values per method. +* A handler that sets the fields on its result overrides the map, per field. +* `"public"` is a promise that the result is identical for every caller. It is not access control. +* `Client` honors the hints automatically: its response cache is on by default, serves fresh entries instead of refetching, and caches nothing for servers (or sessions) that provide no hints. +* Per call, `cache_mode="refresh"` refetches and `"bypass"` skips the cache; `cache=False` at construction turns it off entirely. diff --git a/docs/client/callbacks.md b/docs/client/callbacks.md new file mode 100644 index 0000000..6b4e934 --- /dev/null +++ b/docs/client/callbacks.md @@ -0,0 +1,149 @@ +# Client callbacks + +Nearly every request in MCP goes one way: client to server. + +A server can also ask the **client** for things: to put a question to the user, to sample the user's model, to list the user's workspace folders. You answer those requests by passing **callbacks** to `Client(...)`. + +## A server that asks + +Here is a server whose tool can't finish on its own: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/client_callbacks/tutorial001.py" +``` + +* `ctx.elicit(...)` sends an `elicitation/create` request **to the client** and waits. +* The tool doesn't return until somebody (a person in a form, or your code) supplies a `name`. + +That is the server half, and the **[Elicitation](../handlers/elicitation.md)** page owns it. This page is the other end of the wire. + +## The elicitation callback + +```python title="client.py" hl_lines="7-11 17-18" +--8<-- "docs_src/client_callbacks/tutorial002.py" +``` + +* An elicitation callback is `async (context, params) -> ElicitResult`. +* `params.message` is the question. `params.requested_schema` is the JSON Schema of the answer the server wants. A real client renders a form from it; this one auto-fills. +* You return `ElicitResult(action="accept", content={...})`, or `action="decline"`, or `action="cancel"`. The only other option is `ErrorData(...)`, which refuses the request and fails the whole call. +* `context` is a `ClientRequestContext`: the live `session`, the server's `request_id`, and any `meta` it attached. + +!!! tip + `params` is a union of the two elicitation modes. Here `params.mode` is `"form"`; a `"url"` request + carries `params.url` instead of a schema. One callback handles both; branch on `params.mode`. + **[Elicitation](../handlers/elicitation.md)** shows the full pattern. + +### Try it + +Call `issue_card` and watch both ends. + +Your callback receives the server's question, already parsed: + +```python +params.mode # 'form' +params.message # 'What name should go on the card?' +params.requested_schema # {'properties': {'name': {'title': 'Name', 'type': 'string'}}, + # 'required': ['name'], 'title': 'CardHolder', 'type': 'object'} +``` + +It answers, `ctx.elicit(...)` resumes inside the tool, and the tool finishes: + +```python +result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.')] +``` + +One `tools/call` from you, one `elicitation/create` back from the server, answered by your function, all inside a single tool call. + +!!! info + `mode="legacy"` on line 17 is doing real work. By default `Client(...)` negotiates the modern + protocol path, and that path has no back-channel for server-to-client requests: `ctx.elicit` + fails before your callback ever runs. The transport doesn't decide that; the negotiated + protocol does, in-memory and over a URL alike. Pin `mode="legacy"` whenever your client has + to answer one; every test behind this page does. **[Protocol versions](../protocol-versions.md)** has the whole story. + + On a 2026-07-28 session the callback isn't dead, it's fed differently: when a tool returns an + `InputRequiredResult` carrying an `ElicitRequest`, `Client` dispatches that entry to the same + `elicitation_callback` and retries the call for you. That flow is **[Multi-round-trip requests](../handlers/multi-round-trip.md)**. + +## A callback is a capability + +You never told the server that your client can answer elicitation requests. The SDK did. + +When a client connects it declares its `capabilities`, the mirror image of the server's. You don't write that object. **Registering a callback is the declaration.** + +| you pass | the client declares | +| --- | --- | +| `elicitation_callback=` | `"elicitation": {"form": {}, "url": {}}` | +| `sampling_callback=` | `"sampling": {}` | +| `list_roots_callback=` | `"roots": {"listChanged": true}` | +| none of them | `{}` | + +Sampling sub-capabilities are the one refinement: pass `sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability())` alongside `sampling_callback` when your sampler handles the `tools` / `tool_choice` parameters. Servers must see `sampling.tools` declared before they can send them. + +`logging_callback` and `message_handler` are not in the table. They handle notifications, and notifications need no capability. + +The server reads the declaration back with `ctx.session.check_client_capability(...)`. Add a tool that does: + +```python title="server.py" hl_lines="23-31" +--8<-- "docs_src/client_callbacks/tutorial003.py" +``` + +Connect with only `elicitation_callback` and call it: + +```python +result.structured_content # {'result': ['elicitation']} +``` + +Pass all three callbacks and you get `['elicitation', 'sampling', 'roots']`. Pass none and you get `[]`. + +!!! check + Now do the wrong thing: connect **without** `elicitation_callback` and call `issue_card` anyway. + + The server's `elicitation/create` request still reaches your client, and the SDK answers it for + you, with an error, because you never said you could handle it. That error sinks the whole call. + `call_tool` doesn't return an `is_error` result; it raises: + + ```text + MCPError: Elicitation not supported + ``` + + That is a protocol error (`-32600`, *invalid request*), not a tool error: there is nothing for + the model to read and retry. It's why `client_features` is worth having: a well-behaved server + checks before it asks. + +## The deprecated pair + +`sampling_callback` answers `sampling/createMessage`: the server asking *your* model to complete something. `list_roots_callback` answers `roots/list`: the server asking which directories it may work in. + +Both work. Both follow the rule above. And both serve RPCs the **2026-07-28 spec removes**: a modern server doesn't call back into your client mid-request, it hands the request back to you as part of the tool result (**[Multi-round-trip requests](../handlers/multi-round-trip.md)**). The callbacks themselves are not dead. When an `InputRequiredResult` carries a `CreateMessageRequest` or a `ListRootsRequest`, `Client`'s auto-loop dispatches it to the same `sampling_callback` or `list_roots_callback` you registered here. The whole list is in **[Deprecated features](../deprecated.md)**. + +You still need the callbacks to talk to servers that haven't moved. The signatures: + +```python title="client.py" +--8<-- "docs_src/client_callbacks/tutorial004.py" +``` + +* A sampling callback receives the full `CreateMessageRequestParams` (`messages`, `model_preferences`, `max_tokens`) and returns a `CreateMessageResult`. *You* run the model, however you like; the SDK only carries the request. +* A roots callback takes no params at all and returns a `ListRootsResult`. +* Either one may return `ErrorData(...)` instead, to refuse. + +Pass them to `Client(...)` exactly like `elicitation_callback`. + +## The notification callbacks + +Two more. Neither declares anything. + +`logging_callback` receives every `notifications/message` a server sends, as `LoggingMessageNotificationParams` (`level`, `logger`, `data`). Protocol logging is itself deprecated by the 2026-07-28 spec (**[Logging](../handlers/logging.md)** has what to do instead), so this callback exists for the servers that still emit it. + +`message_handler` is the catch-all: every server notification reaches it (as well as its specific callback), and on a stream-backed transport so does every transport-level `Exception`. The one pattern worth knowing is `if isinstance(message, Exception): raise message`, so a broken connection fails loudly instead of vanishing. + +## Recap + +* A server can send requests to the client. You answer them with callbacks passed to `Client(...)`. +* The elicitation callback is the current one: `async (context, params) -> ElicitResult`, one function for both form and URL mode. +* **Registering a callback is declaring the capability.** Without it, the SDK refuses the server's request on your behalf and the whole call fails with `MCPError`. +* A server finds out before asking with `ctx.session.check_client_capability(...)`. +* `sampling_callback` and `list_roots_callback` work the same way but serve deprecated features; modern servers use multi-round-trip requests instead. +* `logging_callback` and `message_handler` receive notifications. They declare nothing. + +The first argument to `Client(...)` is a transport object. **[Client transports](transports.md)** covers every kind. diff --git a/docs/client/identity-assertion.md b/docs/client/identity-assertion.md new file mode 100644 index 0000000..908f08a --- /dev/null +++ b/docs/client/identity-assertion.md @@ -0,0 +1,146 @@ +# Identity assertion + +An ordinary OAuth provider (**[OAuth clients](oauth-clients.md)**) starts by asking the MCP server a question: *which authorization server do you trust?* It follows the answer wherever it points, and then either a person signs in or a pre-shared secret stands in for one. + +An enterprise wants neither decided per server. It already runs an identity provider (Okta, Microsoft Entra ID, your own); the user already signed in to it this morning; and it is the one place the security team wants to decide who may reach what. [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990), the **Enterprise-Managed Authorization** extension, moves the decision there. The IdP signs a short-lived JWT, an **Identity Assertion JWT Authorization Grant**, the **ID-JAG**: a statement that *this user*, through *this client*, may reach *this MCP server*. The client trades it for an ordinary access token. No browser, no consent screen, no dynamic registration. + +This page is both ends of that trade. The MCP server itself never changes: it is still the resource server from **[Authorization](../run/authorization.md)**, checking whatever token shows up. + +## Two token requests + +Two different authorities are in play, and naming them apart is most of understanding this page. The **enterprise IdP** is your organization's identity provider: it knows who the employee is, it is where policy lives, and it issues the ID-JAG. The SDK never talks to it. The **MCP authorization server** is the same party it was in **[Authorization](../run/authorization.md)**: the issuer named in the MCP server's metadata, the thing that mints the tokens that MCP server accepts. In an ordinary OAuth flow, those two roles are usually one box. Here they are two, and the whole grant is the second agreeing to trust the first. + +The client makes one token request to each. + +1. **To the enterprise IdP.** The client trades the user's sign-in (their OpenID Connect ID token) for the ID-JAG. This is an [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchange, it is entirely your IdP's API, and **the SDK does not make it**. You do, inside one async callback. It is also where the policy decision happens: an IdP that says no never issues the ID-JAG, and there is nothing to present. +2. **To the MCP authorization server.** The client presents the ID-JAG under the [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) `jwt-bearer` grant (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, the ID-JAG as `assertion`) and receives the access token. **This is the request the SDK makes**, and accepting it is the one thing this page adds to an authorization server. + +Everything below is the second request: the client that sends it and the authorization server that answers it. + +## The client + +**`IdentityAssertionOAuthProvider`** lives in `mcp.client.auth.extensions.identity_assertion`. Like every provider in **[OAuth clients](oauth-clients.md)** it is an `httpx.Auth`: construct one, put it on `auth=`, hand the `httpx.AsyncClient` to the transport. + +```python title="client.py" hl_lines="49-50 53-61" +--8<-- "docs_src/identity_assertion/tutorial001.py" +``` + +Read it from the bottom. + +* `main()` is the standard OAuth-client `main()` (**[OAuth clients](oauth-clients.md)**), unchanged line for line. That is the point: once the provider exists, nothing downstream knows which grant produced the token. +* The provider takes what the other providers cannot discover: a `client_id` and `client_secret` somebody **pre-registered** with the authorization server, that authorization server's `issuer`, and `assertion_provider`, an async callback that returns a fresh ID-JAG on demand. +* `storage` is the same `TokenStorage` protocol. Only the two token methods are ever called; there is no dynamic registration here, so there is no `client_info` to remember. + +### The assertion provider + +`fetch_id_jag(audience, resource)` is the only code you write. It is awaited once per token exchange, never at construction, and only *after* the authorization server's metadata has been fetched and validated, so a misconfigured issuer never leaks an assertion. Its two arguments are two of the claims the ID-JAG must be minted with: `audience` is the authorization server's issuer (the ID-JAG `aud`) and `resource` is the MCP server's canonical identifier (the ID-JAG `resource`). The third is one you already hold: the ID-JAG's `client_id` claim must name the `client_id` you gave the provider, or the authorization server refuses the exchange. + +`idp_issue_id_jag` above it is **not your code**. It stands in for the identity provider, signing the assertion in-process so the file is complete and you can read every claim an ID-JAG carries. A real `fetch_id_jag` makes the first token request of the previous section instead: an [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchange against your IdP, defined by the Identity Assertion JWT Authorization Grant draft that [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) profiles. The signed-in user's ID token goes in as the `subject_token`, the `requested_token_type` is the ID-JAG's own URN (`urn:ietf:params:oauth:token-type:id-jag`), `audience` and `resource` pass straight through, and the response carries the ID-JAG. That exchange, under those names, is what to look for in your IdP's documentation. + +!!! tip + A fresh ID-JAG is requested for every exchange, and that is the point: it is a single-use, + minutes-lived grant, and the authorization server on this page refuses to accept the same one + twice. Do not cache it. The access token it buys you is the thing that gets reused. + +### The issuer is configuration + +Here is the inversion. `OAuthClientProvider` asks the resource server which authorization server to use and follows the answer wherever it points. This provider refuses to: `issuer` is required, the [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) metadata is fetched from that issuer's own well-known path, the token endpoint must be on that issuer's origin, and the resource server is never asked anything. + +The extension does not demand this; it is a deliberately stricter choice. This client carries two things worth stealing, a pre-registered secret and an audience-bound assertion, and a client that let a compromised MCP server steer it to an attacker's authorization server would post both to it. Pinning the issuer at construction deletes that conversation. + +!!! warning + The configured `issuer` is compared to the metadata document's `issuer` field by RFC 8414 §3.3 + simple string comparison: character for character, trailing slash included, no normalization. + Do not guess it. Fetch `/.well-known/oauth-authorization-server` from your authorization server + and copy the `issuer` value it returns. For the authorization server on this page that is + `https://auth.example.com/`, with the slash, because its issuer was built from a pydantic URL + object. A mismatch stops the flow at `OAuthFlowError: Authorization server metadata issuer + mismatch` before a single credential or assertion is sent. + +### A confidential client + +`client_secret` is required; the constructor raises `ValueError` without one. The IETF profile underneath [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) reserves this grant for confidential clients, SEP-990 requires the client to authenticate, and this SDK enforces both by insisting on a shared secret. `token_endpoint_auth_method` picks where it travels: `client_secret_post` (the default, in the form body) or `client_secret_basic` (an HTTP Basic header). The profile also permits `private_key_jwt`; this provider does not support it. + +!!! tip + Read `client_secret` from the environment or a secret manager, never from source control. + +### What the provider does for you + +The first request goes out unauthenticated, and the server's `401` starts the flow. + +1. **Discovery.** It fetches the authorization server metadata from the configured issuer's [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) well-known path, checks the document's `issuer` matches, and checks the token endpoint is on the issuer's origin. +2. **The assertion.** It awaits your `assertion_provider`. +3. **Exchange.** It POSTs the `jwt-bearer` grant to the token endpoint, stores the `OAuthToken`, and replays your original request with `Authorization: Bearer ...`. + +A `403` whose `WWW-Authenticate` names `insufficient_scope` runs steps 2 and 3 again with the union of your `scope` and the challenged one. (`scope` is only ever a request; this page's authorization server grants what the ID-JAG says and nothing else.) There is no refresh token anywhere in this: when the access token expires, the next `401` mints a fresh ID-JAG and exchanges again, and *that* is the lever the IdP holds. Failures are the same two exceptions as the rest of **[OAuth clients](oauth-clients.md)**: `OAuthFlowError` for discovery and validation, its subclass `OAuthTokenError` when the token endpoint says no. + +## The authorization server + +Most of the time you stop here. The MCP authorization server is somebody else's product, accepting ID-JAGs is its configuration to turn on, and the SDK's half of [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) is the client above. + +The SDK can also *be* the authorization server: `create_auth_routes` returns the authorization server's routes as a list any Starlette app can mount, which is how `examples/servers/simple-auth/` in the repository runs one. SEP-990 adds one flag and one method to that surface: + +```python title="auth_server.py" hl_lines="48-50 105-107" +--8<-- "docs_src/identity_assertion/tutorial002.py" +``` + +* `identity_assertion_enabled=True` gates everything. Off, which is the default, `/token` answers this grant with `unsupported_grant_type` even if you implemented the hook, and the metadata does not mention it. On, the metadata gains the `jwt-bearer` grant type and lists `urn:ietf:params:oauth:grant-profile:id-jag` in `authorization_grant_profiles_supported`, the field the extension uses to advertise support. (This SDK's client never reads it: it is provisioned for one issuer and simply asks.) +* **`exchange_identity_assertion`** is the hook. Before it runs, the SDK has authenticated the client, refused public clients, and refused clients whose registration does not list the grant. You get an `IdentityAssertionParams` (the raw `assertion`, the requested `scopes` and `resource`) and return a plain `OAuthToken`. +* Dynamic client registration refuses this grant unconditionally, so `get_client` here serves a hand-provisioned client. An ID-JAG client cannot register itself into existence. +* Half the class is refusals. `OAuthAuthorizationServerProvider` is the *whole* authorization server, so it also asks for the authorization-code flow; a server that signs users in as well implements those for real, and this one has exactly one door. + +!!! warning + The SDK never decodes the assertion: only your deployment knows which IdP it trusts and which + keys that IdP publishes, so everything inside `exchange_identity_assertion` is load-bearing. + Verify the signature against the IdP's published keys (its JWKS; the shared secret here is the + demo's), and `iss` and `exp`, per [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) §3. Require the JWT header's `typ` to be + `oauth-id-jag+jwt`, the profile's guard against some other JWT being replayed as a grant. + Require `aud` to be your own issuer. Require the ID-JAG's `client_id` claim to equal the client + the handler authenticated, and its `resource` claim to name a resource you actually serve. + Track `jti` until the assertion's `exp` so it is accepted once. And take the granted scopes + and, above all, the issued token's `resource` from the validated ID-JAG, never from the + request: `params.resource` is whatever the client typed. The full processing rules are in the + [Enterprise-Managed Authorization specification](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization). + +Reject a bad assertion with `TokenError("invalid_grant", ...)`. The other error code in this flow is `invalid_target`: an ID-JAG that names a resource you do not serve is refused with it, which is what stops this server minting tokens for somebody else's. And the granted scopes come from the ID-JAG's `scope` claim (an assertion without one is refused too); yours might map the user's groups instead. + +And notice what the returned `OAuthToken` does not carry: a refresh token. The IdP decides how long this user keeps access by deciding whether to issue the next ID-JAG. A refresh token minted here would quietly hand that decision back. + +!!! info + A server that still embeds its authorization server with `auth_server_provider=` reaches the same + code through `AuthSettings(identity_assertion_enabled=True)`. **[Authorization](../run/authorization.md)** explains why new + servers should not start there. + +!!! check + Wire the two files on this page together and the whole grant is one `POST /token`: + + ```text + grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer + assertion=eyJhbGciOiJIUzI1NiIsInR5cCI6Im9hdXRoLWlkLWphZytqd3QifQ... + client_id=finance-agent + resource=http://localhost:8001/mcp + scope=notes:read + client_secret=finance-agent-secret + + HTTP/1.1 200 OK + {"access_token": "mcp_...", "token_type": "Bearer", "expires_in": 300, "scope": "notes:read"} + ``` + + No `/authorize`, no `/register`, no protected-resource-metadata fetch. The only requests on the + wire are the one that drew the `401`, the well-known fetch, this exchange, and then ordinary + MCP traffic with the bearer attached. And the `sub` your validator read out of the ID-JAG is + exactly what `get_access_token().subject` reports inside a tool. + +### Try it + +`examples/stories/identity_assertion/` in the SDK repository is this page running for real: the same `exchange_identity_assertion` validator, an MCP server gated on its tokens, a stand-in IdP, and the client, in one self-checking program. `uv run python -m stories.identity_assertion.client --http` runs the whole exchange and asserts that the user the IdP named is the user the tool sees. + +## Recap + +* [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) lets the enterprise identity provider, not the end user, decide which MCP servers a client may reach. The IdP signs that decision into an **ID-JAG**. +* Obtaining the ID-JAG is an [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchange against *your IdP*, and the SDK does not make it. Presenting it to the MCP authorization server is the [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) `jwt-bearer` grant, and the SDK does both sides of that. +* `IdentityAssertionOAuthProvider` is another `httpx.Auth`: a pre-registered confidential client, a pinned `issuer`, and one `assertion_provider(audience, resource)` callback. No browser, no registration, no refresh token. +* The authorization server is never discovered from the resource server. Configure `issuer` to exactly the string its metadata document serves; the comparison is character for character. +* Server side, `identity_assertion_enabled=True` plus `exchange_identity_assertion`. The SDK authenticates the client and gates the grant; validating the ID-JAG is entirely yours, and the issued token is bound to the ID-JAG's `resource`, not the request's. + +The one party this page never touched is the MCP server. What it does with the token you just minted, it was already doing in **[Authorization](../run/authorization.md)**. diff --git a/docs/client/index.md b/docs/client/index.md new file mode 100644 index 0000000..ae47508 --- /dev/null +++ b/docs/client/index.md @@ -0,0 +1,212 @@ +# The Client + +A **`Client`** is how a Python program talks to an MCP server. + +It is one object with one lifecycle: construct it, enter `async with`, call methods. Every protocol verb (list the tools, call one, read a resource, render a prompt) is an `async` method on it that returns a typed result. + +## Your first client + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +The server at the top is only there so you have something to connect to. The client is the five highlighted lines. + +* `Client(mcp)` is given the **server object itself**. That is the in-memory transport: no subprocess, no port, no HTTP. It is how every example on this page, and every test you write, connects. +* `async with` is the **lifecycle**. Entering it connects and negotiates; leaving it disconnects. There is no `connect()` / `close()` pair, and a `Client` cannot be reused after the block ends. +* Inside the block the connection facts are already there as plain properties. + +### What you can pass to `Client` + +`Client` takes one positional argument and resolves the transport from its type: + +* An `MCPServer` (or low-level `Server`) instance: connected **in-process**. +* A URL string (`Client("http://localhost:8000/mcp")`): Streamable HTTP, the production path. +* A **transport**: anything you can `async with ... as (read, write)`, such as `stdio_client(...)` wrapping a subprocess. + +Everything else on this page is identical across all three. Headers, subprocesses, timeouts, and the `Transport` protocol get their own page: **[Client transports](transports.md)**. + +### What's on a connected client + +Four read-only properties, populated the moment you enter the block: + +* `client.server_info`: the server's identity. `server_info.name` here is `"Bookshop"`, `server_info.version` is whatever the server reports. +* `client.server_capabilities`: what the server can do (`tools`, `resources`, `prompts`, `completions`, ...). A capability the server doesn't have is `None`. +* `client.protocol_version`: the protocol version the two sides agreed on. Here it is `"2026-07-28"`. +* `client.instructions`: the server's `instructions=` string, or `None` if it didn't set one. + +You never picked a protocol version. By default the `Client` probes the server and falls back to the classic handshake on older ones, so one client works against any era of server. When you need to control that, **[Protocol versions](../protocol-versions.md)** has the whole story. + +!!! tip + `client.session` is the underlying `ClientSession`, the low-level escape hatch. + You won't need it for anything on this page. + +## Listing tools + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial002.py" +``` + +`list_tools()` returns a `ListToolsResult`; the tools are in `.tools`. Each one is the complete definition a host would hand to a model: + +```python +tool.name # 'search_books' +tool.title # 'Search the catalog' +tool.description # 'Search the catalog by title or author.' +``` + +and `tool.input_schema` is the JSON Schema the server derived from the function's type hints: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +That schema is everything a UI needs to render an argument form, and everything a model needs to produce valid arguments. + +!!! tip + `title` is optional, so a UI showing tools to a human has to pick: the `title` if there is one, + the `name` if not. `from mcp.shared.metadata_utils import get_display_name` does exactly that, + for tools, resources, resource templates and prompts. + +## Calling a tool + +`call_tool(name, arguments)` runs the tool and gives you back a `CallToolResult`. + +```python title="client.py" hl_lines="26-33" +--8<-- "docs_src/client/tutorial003.py" +``` + +The server's `lookup_book` returns a Pydantic `Book`. Here is what the client sees: + +```python +result.content # [TextContent(type='text', text='{\n "title": "Dune",\n "author": "Frank Herbert",\n "year": 1965\n}')] +result.structured_content # {'title': 'Dune', 'author': 'Frank Herbert', 'year': 1965} +result.is_error # False +``` + +One return value, three things to read. Each has a different consumer. + +### `content`: what the model reads + +`content` is a `list` of **content blocks**, and a content block is a union: `TextContent`, `ImageContent`, `AudioContent`, `ResourceLink`, or `EmbeddedResource`. A tool can return several, of different kinds. + +That is why `main` narrows with `isinstance(block, TextContent)` before touching `block.text`. Notice there is no `.text` outside the `isinstance`: the type checker won't allow it, because `ImageContent` has `.data`, not `.text`. The union is honest about what a tool is allowed to send you; your code should be too. + +### `structured_content`: what your application reads + +`structured_content` is the tool's return value as JSON, matching the tool's declared `output_schema`. No string parsing, no guessing. + +When both are present they say the same thing twice on purpose: `content` is for a model, `structured_content` is for code. Where the structured half comes from, and how to control it, is the **[Structured Output](../servers/structured-output.md)** page. + +### `is_error`: whether the tool failed + +A tool that raises does **not** raise in your client. It comes back as an ordinary result with `is_error=True`. + +!!! check + Ask `lookup_book` for `"Solaris"` (a title that isn't in the catalog) and the function raises + `ValueError`. The call still returns normally: + + ```python + result.is_error # True + result.content # [TextContent(type='text', text="Error executing tool lookup_book: No book titled 'Solaris' in the catalog.")] + result.structured_content # None + ``` + + The exception's message landed in `content`, where the **model** can read it and try again. That + is deliberate: a tool error is part of the conversation, not a crash. Always look at `is_error` + before you trust `structured_content`. + +!!! warning + `is_error=True` covers more than your own `raise`. Ask for a tool the server doesn't even have + (`call_tool("does_not_exist", {})`) and nothing raises. You get the same shape back, + `is_error=True` with `Unknown tool: does_not_exist` in `content`. A `Client` method raises + `MCPError` only when the server answers with a JSON-RPC **error** instead of a result, and + **[Handling errors](../servers/handling-errors.md)** covers when a server produces which. + +## Resources + +The resource verbs come in pairs: two ways to list, one way to read. + +```python title="client.py" hl_lines="23-32" +--8<-- "docs_src/client/tutorial004.py" +``` + +* `list_resources()` returns the **concrete** resources, the ones with a fixed URI. Here: `['catalog://genres']`. +* `list_resource_templates()` returns the **parameterised** ones. Here: `['catalog://genres/{genre}']`. They are two different lists because a template isn't readable until you fill it in. +* `read_resource(uri)` takes a plain `str` URI and works on both: pass `"catalog://genres/poetry"` and the server matches it to the template. + +`read_resource` returns `contents`, a list of `TextResourceContents` or `BlobResourceContents`. Same idea as tool content: narrow with `isinstance`, then read `.text` (or `.blob`). + +A client can also be told when a resource changes. On 2025-era connections that is `subscribe_resource(uri)` / `unsubscribe_resource(uri)` - a method pair `MCPServer` doesn't implement, so on the 2026-07-28 wire (where those verbs no longer exist) the request answers `-32601`, *Method not found*. The 2026 replacement is a `subscriptions/listen` stream, which `MCPServer` *does* serve - `server_capabilities.resources.subscribe` is `True` there - and consuming it with `client.listen(...)` is this section's **[Subscriptions](subscriptions.md)** page. + +## Prompts + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial005.py" +``` + +`list_prompts()` tells you what the server offers and what each prompt needs: + +```python +prompt.name # 'recommend' +prompt.title # 'Recommend a book' +prompt.arguments # [PromptArgument(name='genre', required=True)] +``` + +`get_prompt(name, arguments)` renders it. The arguments dict is `str -> str`: prompt arguments are always strings. The result is `messages`, a list of `PromptMessage`, each with a `role` and a `content` block: + +```python +message.role # 'user' +message.content # TextContent(type='text', text='Recommend one poetry book from the catalog and say why.') +``` + +A host hands those messages straight to the model. That is the whole feature. + +## Completions + +A server with a completion handler can autocomplete prompt and resource-template arguments as the user types. + +```python title="client.py" hl_lines="28-32" +--8<-- "docs_src/client/tutorial006.py" +``` + +* `ref` says *which* prompt or template you're filling in: a `PromptReference` or a `ResourceTemplateReference`. +* `argument` is `{"name": ..., "value": ...}`: the argument and what the user has typed so far. + +The answer is in `result.completion.values`. Type `"p"` and the server comes back with `['poetry']`. The server side, and how a handler uses the *other* already-filled arguments to narrow its suggestions, is the **[Completions](../servers/completions.md)** page. + +## Pagination + +Every `list_*` method takes a `cursor=` keyword and every result carries a `next_cursor`. When `next_cursor` is `None`, you have everything. + +```python title="client.py" hl_lines="23-31" +--8<-- "docs_src/client/tutorial007.py" +``` + +This loop is correct against every server. `MCPServer` returns everything in one page, so `next_cursor` is `None` and the loop runs once, which is why most code never writes it. Servers that genuinely page, and the rules cursors obey, are in **[Pagination](../advanced/pagination.md)**. + +## In tests + +`Client(mcp)` with no process and no port is already a test harness for your server. + +There is one constructor flag built for that: `Client(mcp, raise_exceptions=True)`. It only has an effect on in-memory connections, and **[Testing](../get-started/testing.md)** is the page that explains it and builds the whole pattern around it. + +## Recap + +* `Client(x)` connects in-memory to a server object, over Streamable HTTP to a URL string, and over anything else via a transport. +* `async with` is the whole lifecycle. Inside it, `server_info`, `server_capabilities`, `protocol_version` and `instructions` are already populated. +* `list_tools()` gives you each tool's `name`, `title`, `description` and `input_schema`. +* `call_tool()` returns `content` for the model, `structured_content` for your code, and `is_error`. A raising tool is a result, not an exception. +* `content` is a union of block types; narrow with `isinstance` before reading. +* `list_resources` / `list_resource_templates` / `read_resource`, `list_prompts` / `get_prompt`, and `complete` round out the verbs. +* Every `list_*` takes `cursor=`; loop until `next_cursor` is `None`. + +The things a server can ask the *client* for, and how you answer them, are **[Client callbacks](callbacks.md)**. diff --git a/docs/client/oauth-clients.md b/docs/client/oauth-clients.md new file mode 100644 index 0000000..bde925d --- /dev/null +++ b/docs/client/oauth-clients.md @@ -0,0 +1,147 @@ +# OAuth clients + +Some MCP servers are protected. Send them a request without a token and they answer `401 Unauthorized`. + +**`OAuthClientProvider`** is how you get the token. It is not an MCP object at all. It is an `httpx.Auth`, the standard httpx hook for "do something to every request". You attach it to an `httpx.AsyncClient`, hand that client to the Streamable HTTP transport, and stop thinking about it. + +This page is the client side. Making your own server demand a token is **[Authorization](../run/authorization.md)**. + +## The provider + +```python title="client.py" hl_lines="44-54" +--8<-- "docs_src/oauth_clients/tutorial001.py" +``` + +You give it four things: + +* `server_url`: the MCP endpoint you are connecting to. The provider discovers everything else from it. +* `client_metadata`: what you would type into an authorization server's "register an application" form. +* `storage`: where tokens live between runs. +* `redirect_handler` and `callback_handler`: the two moments a human is involved. + +Nothing else in the file mentions OAuth. `main()` never sees a token. + +### Client metadata + +`OAuthClientMetadata` is the real [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) registration document, as a Pydantic model. + +You set three fields. The defaults fill in the rest: `grant_types` is already `["authorization_code", "refresh_token"]` and `response_types` is already `["code"]`, which is exactly the flow this provider runs. + +!!! check + Because it is a Pydantic model, it validates **before a single byte goes over the network**. + Leave out `redirect_uris` and construction fails on the spot with a `ValidationError` that + names the field: + + ```text + redirect_uris + Field required [type=missing, input_value={'client_name': 'Bookshop Agent'}, input_type=dict] + ``` + + No browser opened, no half-finished registration left behind on the authorization server. + +### Token storage + +**`TokenStorage`** is a `Protocol` with four async methods. You don't inherit from anything; write the methods and any class is a token store: + +* `get_tokens` / `set_tokens` hold the `OAuthToken`: access token, refresh token, expiry, scope. +* `get_client_info` / `set_client_info` hold the `OAuthClientInformationFull` the authorization server issued when the provider registered you, including your `client_id`. + +The in-memory version above works. It also forgets everything when the process exits, so the next run does the whole dance again. Persist it to a file or your platform's keyring and the next run is silent. + +!!! tip + Store `client_info`, not only the tokens. The provider registers dynamically the first time it + finds no stored `client_info`. Throw it away and you mint a fresh registration on every run. + +### The two handlers + +The authorization code flow needs a human exactly once: someone has to sign in and click "allow". + +* **`redirect_handler`** is awaited with the fully-built authorization URL. The `client_id`, the `redirect_uri`, the `state` and the PKCE challenge are already in it. Your only job is to get a browser there. A desktop app calls `webbrowser.open`; this file prints it. +* **`callback_handler`** is awaited next. It waits until the user lands back on your `redirect_uri` and returns that redirect's query parameters as an `AuthorizationCodeResult`. + +A real client runs a small local HTTP server on the redirect URI instead of calling `input()`. The shape is identical: get redirected, hand back `code`, `state`, and `iss`. + +!!! warning + Pass `state` and `iss` through exactly as they arrived. The provider compares `state` to the one + it generated and `iss` to the issuer it discovered, and refuses a mismatch. They are the CSRF + and server-mix-up defences. + +### Into the `Client` + +Look at `main()`. The provider goes on the **httpx client**, the httpx client goes into `streamable_http_client(url, http_client=...)`, and that transport goes into `Client`. + +`streamable_http_client` has no `auth=` keyword. Anything HTTP-level (auth, headers, timeouts, proxies) belongs on the `httpx.AsyncClient` you bring. That layering is **[Client transports](transports.md)**. + +## What the provider does for you + +The first time `Client` sends a request, the server answers `401`. The provider takes over: + +1. **Discovery.** It reads the `WWW-Authenticate` header, fetches the server's Protected Resource Metadata from `/.well-known/oauth-protected-resource`, learns which authorization server protects this resource, and fetches *that* server's metadata. +2. **Registration.** Nothing in storage? It registers you dynamically with your `OAuthClientMetadata` and stores the result. +3. **Authorization.** It generates the PKCE pair and a `state`, builds the authorization URL, awaits your `redirect_handler`, then awaits your `callback_handler` for the code. +4. **Exchange.** It trades the code for an `OAuthToken`, stores it, and replays your original request with `Authorization: Bearer ...`. + +After that it is quiet. Tokens come out of storage, an expired access token is refreshed with the refresh token, and only when none of that works does it run the flow again. + +You wrote none of it. Three keyword arguments remain (`timeout`, `client_metadata_url` and `validate_resource_url`), and this file needs none of them. `client_metadata_url` is the one worth knowing about; it gets its own section below. + +### Try it + +Most examples in these docs you can check with an in-memory `Client(server)`. Not this: the whole point of the flow is an HTTP `401`, and there is no HTTP between an in-memory client and its server. + +The repository ships the live version. `examples/servers/simple-auth/` runs a standalone authorization server and a protected MCP server; `examples/clients/simple-auth-client/` is this page's client grown into a small CLI. Its README has the two commands: start the servers, run the client against them, and you watch the four steps go by. + +## Client ID Metadata Documents + +The 2026-07-28 revision of the spec deprecates dynamic client registration in favor of **Client ID Metadata Documents** (CIMD). Instead of POSTing a fresh registration to every authorization server it meets, your client publishes one JSON document about itself at a stable HTTPS URL, and that URL *is* its `client_id`. The authorization server fetches the document; the provider never touches it. + +The SDK already speaks it: pass the URL as `client_metadata_url=` when you construct the provider. When the authorization server's metadata advertises `client_id_metadata_document_supported: true`, the provider skips the `/register` request entirely: the URL goes into the flow as the `client_id`, and there is no `client_secret`. When the server doesn't advertise it (most don't yet), or you never pass a URL, the provider falls back to dynamic registration **silently**, and everything above works exactly as described. Stored `client_info` still wins over both. + +The URL must be HTTPS with a non-root path; anything else is a `ValueError` at construction, before any network happens. The shipped `examples/clients/simple-auth-client/` takes it as the `MCP_CLIENT_METADATA_URL` environment variable. + +## Machine to machine + +A nightly job, a CI step, another service. There is no browser and nobody to click "allow". That is the **client credentials** grant: you already hold a `client_id` and a `client_secret`, and the token endpoint is the whole flow. + +`ClientCredentialsOAuthProvider` is the same `httpx.Auth`, minus the human: + +```python title="client.py" hl_lines="4 27-33" +--8<-- "docs_src/oauth_clients/tutorial002.py" +``` + +What changed: + +* No `OAuthClientMetadata`, no handlers. You pass `client_id` and `client_secret`; the provider builds a minimal `client_credentials` registration around them and skips dynamic registration entirely. +* `scopes` is a space-separated string, the OAuth wire format. +* Everything downstream is identical: the same `TokenStorage`, the same `httpx.AsyncClient(auth=...)`, the same `streamable_http_client`. + +By default the secret travels as HTTP Basic auth on the token request (`client_secret_basic`). Pass `token_endpoint_auth_method="client_secret_post"` to put it in the form body instead. Some authorization servers only accept one of the two. + +!!! tip + Read `client_secret` from the environment or a secret manager, never from source control. + +!!! info + One more provider lives in `mcp.client.auth.extensions.client_credentials`: + **`PrivateKeyJWTOAuthProvider`**, for clients that authenticate with a JWT instead of a + shared secret (`private_key_jwt`, the key-pair and workload-identity flavour). It follows + the same pattern: construct one, put it on `auth=`. The same module ships + `SignedJWTParameters` and `static_assertion_provider`, two helpers that build its assertion. + +There is one more no-human situation: the client belongs to an enterprise whose identity provider, not the user, decides which MCP servers it may reach. That is a different grant with its own trust model and its own page, **[Identity assertion](identity-assertion.md)**. + +## When it fails + +When the OAuth flow goes wrong, the provider raises an `OAuthFlowError` from `mcp.client.auth`. It has two subclasses. `OAuthRegistrationError` means the authorization server refused to register you. `OAuthTokenError` means the token endpoint said no. One `except OAuthFlowError:` covers discovery, registration, authorization, and exchange. + +Not everything is a flow error. The network can still fail; those are ordinary `httpx` exceptions and pass through untouched. + +## Recap + +* `OAuthClientProvider` is an `httpx.Auth`. Put it on an `httpx.AsyncClient`, pass that to `streamable_http_client(url, http_client=...)`, and `Client` never knows OAuth happened. +* You supply four things: the server URL, an `OAuthClientMetadata`, a `TokenStorage`, and the redirect/callback handler pair. +* `TokenStorage` is a `Protocol`: four async methods, no base class. Persist `client_info` as well as the tokens. +* Discovery, registration (dynamic, or via a **Client ID Metadata Document**), PKCE, the `state` and `iss` checks, and token refresh are the provider's job, not yours. +* `ClientCredentialsOAuthProvider` is the no-human version: `client_id` + `client_secret`, no handlers, no browser. +* Every OAuth failure is an `OAuthFlowError`; `OAuthRegistrationError` and `OAuthTokenError` are its subclasses. + +The other half of this handshake, making your *server* demand the token, is **[Authorization](../run/authorization.md)**. diff --git a/docs/client/session-groups.md b/docs/client/session-groups.md new file mode 100644 index 0000000..c7a1434 --- /dev/null +++ b/docs/client/session-groups.md @@ -0,0 +1,82 @@ +# Session groups + +A `Client` connects to one server. Real applications often want several (a search server, a database server, an internal API) and end up juggling a connection and a tool list for each. + +**`ClientSessionGroup`** is one object that holds many connections and merges everything they expose into a single view. + +## Two servers + +Start with two ordinary servers. They have nothing to do with each other, so both naturally called their tool `search`: + +```python title="library_server.py" hl_lines="7" +--8<-- "docs_src/session_groups/tutorial001.py" +``` + +```python title="web_server.py" hl_lines="7" +--8<-- "docs_src/session_groups/tutorial002.py" +``` + +## One group + +Create a `ClientSessionGroup` and call **`connect_to_server`** once per server: + +```python title="client.py" hl_lines="10-12" +--8<-- "docs_src/session_groups/tutorial003.py" +``` + +* `connect_to_server` takes transport parameters, not a server object: `StdioServerParameters` (from `mcp`) to launch a subprocess, or `StreamableHttpParameters` / `SseServerParameters` (from `mcp.client.session_group`) for a server already listening on a URL. +* `group.tools` is a `dict[str, Tool]` of every connected server's tools. `group.resources` and `group.prompts` are the same shape. +* `group.call_tool(name, arguments)` looks the name up, finds the session that owns it, and forwards the call. You never say which server. + +!!! check + Put `client.py` next to the two servers and run it. The second `connect_to_server` refuses: + + ```text + mcp.shared.exceptions.MCPError: {'search'} already exist in group tools. + ``` + + That is an `MCPError`, raised before anything from the second server is registered. A name must + be unique across the **whole** group, and two servers you don't control will collide eventually. + +## `component_name_hook` + +You fix this at the group, not at the servers. Pass a function of `(name, server_info)` and the group runs it on every name it registers: + +```python title="client.py" hl_lines="8-9 16" +--8<-- "docs_src/session_groups/tutorial004.py" +``` + +Run it again. `print(sorted(group.tools))` now shows both: + +```text +['Library.search', 'Web.search'] +``` + +* The **key** is yours. `by_server` built it from `server_info.name`, the name each `MCPServer(...)` was constructed with. +* The `Tool` inside is untouched: `group.tools["Web.search"].name` is still `"search"`, and that is the name `call_tool` puts on the wire. The prefix never leaves your process. +* It is not only tools. The library's `hours` resource is registered as `Library.hours`. + +!!! tip + The hook runs on **every** name from **every** server, not only on conflicts: there is no + prefix-on-collision mode. Pick one scheme and let it apply everywhere. + +## Adding and removing servers + +`connect_to_server` returns the `ClientSession` it opened. Keep it if you ever want that server gone: `await group.disconnect_from_server(session)` removes its tools, resources, and prompts from the group. + +If you already hold a connected `ClientSession` (`Client.session` is one), hand it to `await group.connect_with_session(server_info, session)` instead of opening a new transport. It aggregates the same way. The group never closes a session it didn't open. + +## The classic handshake + +`ClientSessionGroup` is built on `ClientSession`, not on `Client`. Each `connect_to_server` runs the classic `initialize` handshake. It never sends the `server/discover` probe described in **[Protocol versions](../protocol-versions.md)**. Every MCP server understands that handshake, so this costs you compatibility with nothing; it only means a group takes the older, slower path to a server that could do better. + +## Recap + +* `ClientSessionGroup` holds many server connections and merges their tools, resources, and prompts into one `dict` each. +* `connect_to_server(params)` per server. It takes transport parameters, never the server object or URL a `Client` takes. +* `group.call_tool(name, arguments)` routes to the owning server for you. +* Names must be unique across the whole group; two servers with a `search` tool cannot coexist on their own. +* `component_name_hook=` rewrites every registered name. The dict key changes, the wire name does not. +* `connect_with_session` adds a session you already hold; `disconnect_from_server` removes one. + +The handshake a group speaks (and the faster one a `Client` prefers) is the subject of **[Protocol versions](../protocol-versions.md)**. diff --git a/docs/client/subscriptions.md b/docs/client/subscriptions.md new file mode 100644 index 0000000..fc7d013 --- /dev/null +++ b/docs/client/subscriptions.md @@ -0,0 +1,86 @@ +# Subscriptions + +A server's catalog is not fixed. Tools appear at runtime, and the content behind a resource URI changes. A client hears about it through `client.listen(...)`: one `subscriptions/listen` request whose response *is* the stream. It stays open and carries the change notifications the client asked for. + +This page is the client end: opening the stream, watching it beside your main flow, and handling its endings. Publishing changes, filtering, and serving the method are the server's side of the story, told in **[Subscriptions](../handlers/subscriptions.md)** under *Inside your handler*. The examples here talk to the sprint-board server built there. + +## Watching the stream + +A subscription is one context manager. Entering it sends the request, with your keyword arguments as the subscription filter, and waits for the server's acknowledgment, so the stream is live by the time the block starts. + +```python title="client.py" hl_lines="16 19 29" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +Iteration yields four typed events: `ToolsListChanged`, `PromptsListChanged`, `ResourcesListChanged`, and `ResourceUpdated(uri=...)`. + +An event says *what* changed, never *how*. That is why `follow_board` calls `read_resource` and `list_tools`: the event is a cue to refetch. Read `event.uri` rather than assuming which resource moved: a filter can name several URIs, and a server may report a change on a sub-resource of one of them. + +Duplicate events waiting to be consumed collapse into one, and refetching still gets you the current state. Only identical events collapse: two `ResourceUpdated` for different URIs are two events. + +Two more properties of the handle: + +* `sub.honored` is the filter the server acknowledged: a `SubscriptionFilter` with the fields you passed, read as attributes (`sub.honored.prompts_list_changed`). `MCPServer` honors every kind you ask for, so it echoes your request back. A server that narrows the filter (see the [filter warning](../handlers/subscriptions.md#only-what-was-asked-for) on the server page) acknowledges less, and an honored kind may still never fire. +* `sub.subscription_id` is the listen request's id, the one stamped on every frame of this stream. Several subscriptions can be open at once, each demultiplexed by its own id. + +## Watching without blocking + +`follow_board` runs until the server closes the stream, which may be never, so on its own it owns your program. Real clients want the watcher *beside* the main flow: an agent calls tools while a watcher keeps a cache or a UI current. + +Open the subscription first, then start the watcher and get on with your work. + +=== "asyncio" + + ```python title="app.py" hl_lines="18 20" + --8<-- "docs_src/subscriptions/tutorial004_asyncio.py" + ``` + +=== "trio" + + ```python title="app.py" hl_lines="18 21" + --8<-- "docs_src/subscriptions/tutorial004_trio.py" + ``` + +=== "anyio" + + ```python title="app.py" hl_lines="18 21" + --8<-- "docs_src/subscriptions/tutorial004_anyio.py" + ``` + +!!! note + `app.py` imports `BOARD` and `read_board` from the first example, which this repo stores as + `tutorial003.py`. If you save the rendered files side by side as `client.py` and `app.py`, + write `from client import BOARD, read_board` instead. The `watch.py` example further down + imports `read_board` the same way. + +The order is the point. Nothing is replayed, so an event published before your stream existed is missed. Entering `client.listen(...)` waits for the acknowledgment, so every change from that moment on reaches your watcher, and the snapshot you take inside the block cannot miss one. + +Requests run freely beside an open stream, from the watcher task or any other, on the same client. Because *duplicate* unconsumed events coalesce, a busy main flow may produce one refetch rather than three. Events that differ do not coalesce: a filter naming many URIs queues one pending event per URI. + +To stop watching, leave the block: there is no `unsubscribe` call. Cancelling the task that owns the block does that for you, and the SDK cancels the listen request the way the transport expects: over streamable HTTP, by closing that request's stream. A watcher that runs for the life of your app never returns on its own, so cancel it, or its task group's scope, at shutdown. + +## Streams end + +A stream ends in one of two ways, both ordinary control flow. A graceful server close ends the `async for`; an abrupt drop raises `SubscriptionLost`. + +The difference is diagnostic, not a difference in what to do next: the stream is gone, nothing was replayed, and a watcher that still cares re-listens and refetches. + +```python title="watch.py" hl_lines="16 20" +--8<-- "docs_src/subscriptions/tutorial005.py" +``` + +Servers close streams gracefully for their own reasons, including shedding a subscriber whose backlog grew too large, so a clean end is not a signal to stop watching. Back off before re-listening. + +`SubscriptionLost` has one local cause too. The client holds at most 1024 unconsumed events, and a consumer that falls that far behind loses the subscription rather than grow without bound. Keep the body of the `async for` short and do slow work elsewhere. + +`keep_following` catches only `SubscriptionLost`. Entering `listen()` can also raise `MCPError` (the connection failed, or the server does not serve the method), `TimeoutError` (no acknowledgment arrived), and `ListenNotSupportedError` (a pre-2026 connection). Decide which of those your watcher should retry: the last never heals. + +## Recap + +* Enter `async with client.listen(...)`; entering waits for the acknowledgment, so nothing published after it is missed. +* Iterate with `async for event in sub`. Events are cues to refetch, never payloads. +* Open the subscription, then run the watcher as a task, and tool calls keep flowing beside it. +* A clean end stops the loop; a drop raises `SubscriptionLost`. Either way: re-listen, refetch, back off first. +* Leaving the block is the unsubscribe. + +Publishing these events, narrowing the filter, and scaling past one process are the server's story: **[Subscriptions](../handlers/subscriptions.md)**. These same events also keep a client-side cache honest, and **[Caching](caching.md)** is the next page. diff --git a/docs/client/transports.md b/docs/client/transports.md new file mode 100644 index 0000000..1587a1a --- /dev/null +++ b/docs/client/transports.md @@ -0,0 +1,115 @@ +# Client transports + +Every `Client` talks to its server over a **transport**: the thing that actually carries the messages. + +You never configure one separately. `Client` takes a single positional argument and works the transport out from its type. + +The *server* side of each (what `mcp.run()` does and what you deploy) is **[Running your server](../run/index.md)**. + +## In memory + +Pass the server object itself: + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/client_transports/tutorial001.py" +``` + +No subprocess, no port, no bytes on a wire. The client and the server are two objects in the same process, and the call still goes through the real protocol layer: `search_books` is listed, validated and invoked exactly as it would be over HTTP. + +That makes it two things at once: + +* **A test harness.** Every example in this documentation is exercised this way, and the **[Testing](../get-started/testing.md)** page builds the whole pattern around it. +* **An embedding API.** An application that constructs the server doesn't need a network hop to call its tools. + +## Streamable HTTP + +Pass a URL string and you get **Streamable HTTP**, the transport you deploy behind: + +```python title="client.py" hl_lines="5" +--8<-- "docs_src/client_transports/tutorial002.py" +``` + +That is the whole production client. `Client` wraps the URL in `streamable_http_client(...)` for you, on top of an `httpx.AsyncClient` configured the way MCP needs: `follow_redirects=True`, a 30-second timeout for connect/write/pool, and a 300-second read timeout because the server may hold a response stream open. + +!!! check + A `Client` you have constructed is **not** connected. Construction only picks the transport; + `async with` is what opens it. Reach for the connection before entering and the SDK tells you so: + + ```text + RuntimeError: Client must be used within an async context manager + ``` + + Nothing was resolved, fetched or spawned when you wrote `Client("http://...")`. That line is free. + +### Bring your own `httpx.AsyncClient` + +The moment you need an `Authorization` header, a cookie, a proxy, mTLS, or a different timeout, build the `httpx.AsyncClient` yourself and hand it to `streamable_http_client`: + +```python title="client.py" hl_lines="8-14" +--8<-- "docs_src/client_transports/tutorial003.py" +``` + +Two things to notice: + +* You own the `httpx.AsyncClient`, so **you** enter and exit it. The SDK never closes a client it didn't create. +* `streamable_http_client(url, http_client=...)` returns a transport, and `Client(transport)` accepts it like anything else. + +!!! warning + `streamable_http_client` used to take `headers=` and `timeout=` directly. It does not any more: + its only parameters are `url`, `http_client` and `terminate_on_close`. Reach for `headers=` out + of habit and you get: + + ```text + TypeError: streamable_http_client() got an unexpected keyword argument 'headers' + ``` + + Everything HTTP-shaped now lives on the one `httpx.AsyncClient` you pass in. + +!!! info + If you know `httpx`, you already know how to do auth, proxies, event hooks, retries and connection + limits here. The SDK adds nothing on top and takes nothing away. It is also where OAuth plugs in: + `httpx.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **[OAuth clients](oauth-clients.md)**. + +## stdio + +A **stdio** server is a subprocess. The client launches it, writes JSON-RPC to its stdin and reads JSON-RPC from its stdout. It is how a desktop host runs a server on your machine: a host *is* this code plus a UI, and **[Connect to a real host](../get-started/real-host.md)** is the same relationship seen from the host's side, as a config file. + +Describe the process with `StdioServerParameters`, turn it into a transport with `stdio_client`, and hand *that* to `Client`: + +```python title="client.py" hl_lines="4-8 12" +--8<-- "docs_src/client_transports/tutorial004.py" +``` + +`Client` does not accept the parameters object on its own. `StdioServerParameters` is configuration; `stdio_client(server)` is the transport that knows how to spawn a process from it. Always wrap. + +Leaving the `async with` block also shuts the subprocess down: close stdin, wait, kill if it lingers. You never clean it up yourself. + +!!! warning + The child does **not** inherit your environment. It gets a minimal allow-list (`HOME`, `LOGNAME`, + `PATH`, `SHELL`, `TERM` and `USER` on POSIX) so nothing sensitive leaks into a process you may + not have written. + + A server that needs an API key won't find it there. Pass it explicitly with `env=`; those + variables are merged on top of the allow-list. That is what `BOOKSHOP_API_KEY` is doing above. + +## SSE + +`sse_client(url)`, from `mcp.client.sse`, is the HTTP transport that Streamable HTTP superseded. Wrap it the same way, `Client(sse_client("http://localhost:8000/sse"))`, to talk to a server that still speaks it, and don't build anything new on it. + +## The `Transport` protocol + +To `Client`, all of the above are the same thing. + +A **transport** is any async context manager that yields a `(read, write)` pair of message streams: formally, the `Transport` protocol in `mcp.client`. `Client` resolves its argument by type: a server object connects in-process, a `str` becomes `streamable_http_client(url)`, and anything else is entered as a transport directly. That last rule is why `stdio_client(...)`, `streamable_http_client(...)` and `sse_client(...)` all drop into the same slot, and why you can write your own. + +## Recap + +* `Client(mcp)` (the server object) connects in memory. Use it for tests and for embedding. +* `Client("http://.../mcp")` (a URL) connects over Streamable HTTP, the production transport. +* Headers, auth, proxies and timeouts belong on an `httpx.AsyncClient` you pass to `streamable_http_client(url, http_client=...)`. There is no `headers=` keyword. +* stdio is `Client(stdio_client(StdioServerParameters(...)))`, never the parameters object alone. +* The subprocess gets an allow-listed environment, not yours; `env=` adds to it. +* A transport is anything you can `async with x as (read, write)`. `Client` hands anything that isn't a server object or a URL straight to that protocol. +* Constructing a `Client` picks the transport. `async with` opens it. + +Once the transport is open the two sides have to agree on a protocol version. You normally never think about it; when you do, **[Protocol versions](../protocol-versions.md)** is the page. diff --git a/docs/deprecated.md b/docs/deprecated.md new file mode 100644 index 0000000..9b879f1 --- /dev/null +++ b/docs/deprecated.md @@ -0,0 +1,91 @@ +# Deprecated features + +The 2026-07-28 spec retires five things. The SDK still implements every one of them, and every one of them now carries a **deprecation warning**. + +The table below names each deprecated feature, why it is going away, and the replacement to build on. + +## What is deprecated + +| Deprecated | Why | What you do instead | +|---|---|---| +| **Roots**: `ctx.session.list_roots()`, `client.send_roots_list_changed()`, the `list_roots_callback=` you pass to `Client(...)` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) retires the capability. | Take the paths as ordinary tool arguments or resource URIs, or embed a `ListRootsRequest` in an `InputRequiredResult` (see **[Multi-round-trip requests](handlers/multi-round-trip.md)**). | +| **Server-initiated sampling**: `ctx.session.create_message()`, the `sampling_callback=` you pass to `Client(...)` | SEP-2577 retires the capability. | Return `InputRequiredResult` and let the client retry the call (see **[Multi-round-trip requests](handlers/multi-round-trip.md)**). | +| **Protocol logging**: `ctx.log()`, `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`, `ctx.session.send_log_message()`, `client.set_logging_level()` | SEP-2577 retires the capability. Nothing in-protocol replaces it. | Ordinary `import logging` to stderr (see **[Logging](handlers/logging.md)**). | +| **`ping`**: `client.send_ping()` | **Removed** from the protocol, not merely deprecated. There is no `ping` method in 2026-07-28. | Nothing. It only works against a `mode="legacy"` connection. | +| **Client->server progress**: `client.send_progress_notification()` | 2026-07-28 makes progress server->client only. | Nothing to send. Your *server* reports progress with `ctx.report_progress()` (see **[Progress](handlers/progress.md)**). | + +Three things fall out of that table: + +* Roots, sampling, and logging go together. One proposal, **SEP-2577**, deprecates all three capabilities at once. +* Sampling and roots share a deeper problem: they are places a **server** sends a **request** to the **client**. That whole direction is what 2026-07-28 replaces with **[Multi-round-trip requests](handlers/multi-round-trip.md)**. It is the standalone RPC methods (`sampling/createMessage`, `roots/list`, and push-style `elicitation/create`) that are gone; the `CreateMessageRequest` / `ListRootsRequest` / `ElicitRequest` payload types survive, embedded in `InputRequiredResult.input_requests`, and on the client they hit the same callbacks. +* `ping` is the odd one out. The protocol does not deprecate it, it removes it. The SDK method still warns (its message says *removed*, not *deprecated*) and calling it on a modern connection answers with *"Method not found"*. + +## Deprecated is advisory + +Nothing breaks today. + +Every method above keeps working against any session that negotiated **2025-11-25 or earlier**. Pin `mode="legacy"` on the client and you get exactly the pre-2026 behaviour. There are no wire changes and capability negotiation is unchanged. + +What changes is that you get a visible warning the first time each one runs: + +```text +MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577). +``` + +`MCPDeprecationWarning` subclasses `UserWarning`, **not** `DeprecationWarning`. That is deliberate: Python's default filter only shows `DeprecationWarning` in code run directly as `__main__`, which is how libraries deprecate things and nobody notices for two years. This one shows up everywhere, with no `-W` flag. + +!!! warning + "Advisory" stops at the wire. Sampling and roots are server-to-client *requests*, and a + 2026-07-28 session has no channel to carry one. Call `ctx.session.create_message()` + inside a tool on a modern connection and the warning still fires, and then the send + fails with an error: + + ```text + Cannot send 'sampling/createMessage': this transport context has no back-channel + for server-initiated requests. + ``` + + Two signals, in that order. The `MCPDeprecationWarning` fires the moment you call the + method, on any connection. The error is what comes back when the SDK then tries to + send. These two only work end-to-end on a `mode="legacy"` connection whose client + registered the matching callback. + +## Silencing the warning + +Don't, in new code. + +But a server you maintain that genuinely serves pre-2026 clients has every right to a quiet log. Filter the category before the first deprecated call runs: + +```python +import warnings + +from mcp import MCPDeprecationWarning + +warnings.filterwarnings("ignore", category=MCPDeprecationWarning) +``` + +That is the whole API. There is no per-method switch, and you don't want one: the point of one category is that one line silences it and one line brings it back. + +!!! check + Run the filter the other way and you get a free regression test. Add + `"error::mcp.MCPDeprecationWarning"` to the `filterwarnings` setting in your pytest + configuration and the deprecated call **raises** instead of warning. A tool named + `old_log` that still calls `ctx.info()` stops passing and starts reporting: + + ```text + Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577). + ``` + + One line of pytest configuration, and a deprecated call can never sneak back into your + codebase without failing a test. + +## Recap + +* The 2026-07-28 spec deprecates **roots**, server-initiated **sampling**, and protocol **logging** (all [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), restricts **progress** to server-to-client, and removes **`ping`**. +* The replacement column points you onward: **[Multi-round-trip requests](handlers/multi-round-trip.md)** for sampling and roots, **[Logging](handlers/logging.md)** for logging, **[Progress](handlers/progress.md)** for progress. `ping` needs nothing at all. +* Deprecated is advisory: no wire changes, everything keeps working against pre-2026 sessions, and you get a visible `MCPDeprecationWarning` (a `UserWarning`, so it is on by default). +* Sampling and roots additionally need a back-channel that a 2026-07-28 session does not have. On a modern connection they warn and then they raise. +* `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` silences the whole category; `"error::mcp.MCPDeprecationWarning"` in pytest turns it into a test failure. +* New code should not be built on any of these. + +Every other page in these docs teaches the current API. diff --git a/docs/extra.css b/docs/extra.css new file mode 100644 index 0000000..fb7e123 --- /dev/null +++ b/docs/extra.css @@ -0,0 +1,107 @@ +/* Sidebar hierarchy + density for Zensical's UI (Material-compatible md-* + DOM, but different stock spacing: nav links are 8px-radius pills with + 7px 16px padding). All rules scoped to the desktop sidebar breakpoint + (>= 76.25em) so the mobile drill-down drawer keeps stock styling. Colors + use the md-* tokens, so the light and slate schemes both work without + extra palette handling. */ + +@media screen and (min-width: 76.25em) { + /* The sidebar is one coordinate system derived from the pill inset: + every row — page links, group rows, section labels — is a direct + .md-nav__link child of its item with the same 10px horizontal padding, + so all text shares one column, and hover/active pills always paint + 10px of breathing room inside the scroll container (never clipped). + The padding lives on the elements Zensical paints hover/active pills + on (.md-nav__link[href] anchors and [for] labels — leaf links, bare + section labels, and the inner anchor of an .md-nav__container + wrapper); wrappers stay geometry-neutral, as stock. The 10px inset + also stays >= the 0.4rem pill radius, so the corner curve never + crowds the text. Vertical rhythm has a single knob: the nav list's + flex gap (stock 0.2rem reads airy; 2px matches the density the site + shipped with on Material, ~30px row pitch). */ + .md-sidebar--primary .md-nav__list { + gap: 2px; + } + .md-sidebar--primary .md-nav__item > .md-nav__link:not(.md-nav__container), + .md-sidebar--primary .md-nav__container > .md-nav__link { + padding: 3px 10px; + margin: 0; + } + .md-sidebar--primary .md-nav__item > .md-nav__container { + padding: 0; + margin: 0; + } + + /* Section labels: typography only — geometry comes from the row rule + above, so no specificity coordination is needed. */ + .md-sidebar--primary .md-nav__item--section { + margin: 0.8em 0; + } + .md-sidebar--primary .md-nav__item--section > .md-nav__link { + font-size: 0.62rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--md-default-fg-color--light); + } + + /* Guide lines: 12px from the item box = 2px right of the label text + (which sits at box + 10px pill inset); children indent past them. */ + .md-sidebar--primary .md-nav__item--section > .md-nav { + margin-inline-start: 12px; + border-inline-start: 0.05rem solid var(--md-default-fg-color--lightest); + } + .md-sidebar--primary .md-nav__item--nested:not(.md-nav__item--section) > .md-nav { + margin-inline-start: 12px; + border-inline-start: 0.05rem solid var(--md-default-fg-color--lightest); + } + + /* The current page stands out from its siblings (on top of the stock + pill highlight). 700 because only Inter 300/400/700 are loaded. */ + .md-sidebar--primary .md-nav__link--active { + font-weight: 700; + } + + /* The sidebar repeats the site name right above the homepage nav entry; + drop the title row on desktop (the mobile drawer still needs it for its + drill-down back-navigation, hence the media-query scope). */ + .md-sidebar--primary .md-nav--primary > .md-nav__title { + display: none; + } +} + +/* Dark scheme: Zensical's slate canvas is near-black (hsla(225,15%,5%)), + harsher than the Material slate this site shipped with; restore that + blue-grey. Code blocks and other surfaces keep Zensical's own tokens. */ +@media screen { + [data-md-color-scheme="slate"] { + --md-default-bg-color: #1e2129; + } +} + +/* Inline code inside admonitions: the chip token is an absolute dark + surface designed for the page canvas, so on a tinted admonition panel it + sits as an opaque slab (Zensical's own docs share this bug). Re-tint it + tone-on-tone instead — translucent foreground, composited over whatever + the panel color is — the same pattern Starlight and Docusaurus ship for + code inside callouts. Prose chips keep the block-matching dark surface; + block code inside admonitions keeps its own surface too. The first + declaration is the fallback where color-mix is unsupported. */ +.md-typeset .admonition :not(pre) > code, +.md-typeset details :not(pre) > code { + background-color: var(--md-default-fg-color--lightest); + background-color: color-mix(in srgb, currentcolor 11%, transparent); + color: inherit; +} + +/* Headings: the 300-weight light-gray defaults read washed out; use the + full foreground color and a solid weight instead. 700, not 600: only + Inter 300/400/700 are loaded (see the nav__link note above). */ +.md-typeset h1, +.md-typeset h2 { + font-weight: 700; + color: var(--md-default-fg-color); +} +.md-typeset h3 { + font-weight: 700; +} diff --git a/docs/favicon.svg b/docs/favicon.svg new file mode 100644 index 0000000..a280d7f --- /dev/null +++ b/docs/favicon.svg @@ -0,0 +1,11 @@ +<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="180" height="180" rx="24" fill="black"/> +<mask id="mask0_246_1229" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="7" y="7" width="166" height="166"> +<path d="M173 7H7V173H173V7Z" fill="white"/> +</mask> +<g mask="url(#mask0_246_1229)"> +<path d="M23.5996 85.2532L86.2021 22.6507C94.8457 14.0071 108.86 14.0071 117.503 22.6507C126.147 31.2942 126.147 45.3083 117.503 53.9519L70.2254 101.23" stroke="white" stroke-width="11.0667" stroke-linecap="round"/> +<path d="M70.8789 100.578L117.504 53.952C126.148 45.3083 140.163 45.3083 148.806 53.952L149.132 54.278C157.776 62.9216 157.776 76.9357 149.132 85.5792L92.5139 142.198C89.6327 145.079 89.6327 149.75 92.5139 152.631L104.14 164.257" stroke="white" stroke-width="11.0667" stroke-linecap="round"/> +<path d="M101.853 38.3013L55.553 84.6011C46.9094 93.2447 46.9094 107.258 55.553 115.902C64.1966 124.546 78.2106 124.546 86.8543 115.902L133.154 69.6025" stroke="white" stroke-width="11.0667" stroke-linecap="round"/> +</g> +</svg> diff --git a/docs/get-started/first-steps.md b/docs/get-started/first-steps.md new file mode 100644 index 0000000..ad2f4c6 --- /dev/null +++ b/docs/get-started/first-steps.md @@ -0,0 +1,139 @@ +# First steps + +The **[landing page](../index.md)** moves fast: write a server, run it, call a tool. + +This page takes it slowly, with all three things a server can expose, and a name for everything along the way. + +## Host, client, and server + +Three words you'll see on every page from here on: + +* A **host** is the LLM application: Claude, an IDE, an agent runtime. It's the thing the user is talking to. +* A **client** lives inside the host and speaks MCP. The host runs one client per server it's connected to. +* A **server** is what you build with this SDK. It exposes things to clients. It never talks to the model directly. + +You write the server. Hosts are someone else's product. The SDK also gives you a `Client`. You'll use it to test your servers, and it shows up later on this page. + +## The three primitives + +A server exposes exactly three kinds of thing. What separates them is **who decides to use them**: + +| Primitive | Controlled by | What it is | Example | +|---------------|-----------------|-----------------------------------------------------|------------------------------------| +| **Tools** | The model | A function the model calls to take an action | An API call, a database write | +| **Resources** | The application | Data the host loads into the model's context | A file's contents, an API response | +| **Prompts** | The user | A reusable message template the user invokes by name | A slash command, a menu entry | + +"Controlled by" is the whole point of the split. A tool runs because the **model** decided to call it. A resource is attached because the **application** decided the model needed it. A prompt runs because the **user** picked it. + +!!! info + If you've built a web API you already have most of the intuition: a **resource** is a `GET` + (it loads data and changes nothing) and a **tool** is a `POST` (it does work and may have + side effects). A **prompt** has no HTTP analogue; it's closer to a saved query the user runs + by name. + +## One server, all three + +```python title="server.py" hl_lines="6 12 18" +--8<-- "docs_src/first_steps/tutorial001.py" +``` + +Three plain functions, three decorators. Each decorator is the entire registration: + +* `@mcp.tool()` makes `add` a **tool**. +* `@mcp.resource("greeting://{name}")` makes `greeting` a **resource template**: the `{name}` in the URI is the function's parameter. +* `@mcp.prompt()` makes `summarize` a **prompt**. The string it returns becomes a user message. + +Everything else (the name, the description, the argument schema) the SDK reads from the function itself: its name, its docstring, its type hints. You never declared any of it separately. + +!!! tip + The two halves of the SDK have two import paths: `from mcp import Client` and + `from mcp.server import MCPServer`. There is no `from mcp import MCPServer`. + +### Try it + +Run it with the MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Open the URL it prints. The Inspector has one tab per primitive; walk through them in order. + +**Tools.** One entry: `add`, described as *Add two numbers.* The form has a required integer field for `a` and another for `b`. Fill them in, call it, and the result is `3`. The Inspector built that form from `a: int, b: int`. So does every other client. + +**Resources.** The *Resources* list is empty. `greeting` is under **Resource Templates**, because `greeting://{name}` has a parameter: there is no single resource to list until someone supplies a `name`. Give it `World` and read it: + +```text +Hello, World! +``` + +**Prompts.** One entry: `summarize`, with a single required `text` argument. Get it with some text and you receive one message with `role: user` and your rendered string as the content. That's all a prompt is: a function that builds messages. + +The Inspector ran your server over **stdio**, one of the transports an MCP server can speak. You don't pick one yet; **[Running your server](../run/index.md)** is the page for that. + +## Capabilities + +You saw three tabs in the Inspector. How did it know there were three? + +When a client connects, the server declares its **capabilities**: which families of requests it will answer. The client uses that declaration to decide what to even ask for. You never wrote it; `MCPServer` declares it for you. + +Look at it yourself. The SDK's `Client` accepts the server object directly and connects to it **in memory** (no subprocess, no port): + +```python +import asyncio + +from mcp import Client + +from server import mcp + + +async def main() -> None: + async with Client(mcp) as client: + print(client.server_capabilities.model_dump(exclude_none=True)) + + +asyncio.run(main()) +``` + +```text +{'prompts': {'list_changed': True}, 'resources': {'subscribe': True, 'list_changed': True}, 'tools': {'list_changed': True}} +``` + +That dictionary is your server's declared **capabilities**. It's the first thing every connecting client learns: + +| Capability | The client may now call | +|-------------|------------------------------------------------------------| +| `tools` | `tools/list`, `tools/call` | +| `resources` | `resources/list`, `resources/templates/list`, `resources/read` | +| `prompts` | `prompts/list`, `prompts/get` | + +`MCPServer` serves all three primitives, so all three are always declared. + +Notice what isn't there. `completions` (argument autocomplete for resource templates and prompts) needs a handler you write, this server doesn't have one, so the capability is absent and a well-behaved client won't ask. That's the rule for everything optional: register the thing and the capability appears; **[Completions](../servers/completions.md)** proves it. + +!!! info + `Client(mcp)` is the same in-memory client every example in these docs is tested with, and + it's how you'll test yours. It gets a whole page: **[Testing](testing.md)**. + +## What you did not write + +Look back over this page. You wrote three small Python functions. You did **not** write: + +* A JSON Schema. `a: int, b: int` *is* the schema for `add`. +* A request handler. `tools/list`, `resources/read`, `prompts/get`: all served for you. +* A capability declaration. `MCPServer` made it for you. +* A line of protocol. The version negotiation, the JSON-RPC framing, the capability exchange: all of it happened inside `mcp dev` and `Client(mcp)`, and you never saw it. + +That ratio is the whole point of the SDK. + +## Recap + +* A **host** is the LLM app, a **client** is its MCP-speaking half, a **server** is what you build. +* Tools are **model**-controlled, resources are **application**-controlled, prompts are **user**-controlled. +* One decorator per primitive: `@mcp.tool()`, `@mcp.resource(uri)`, `@mcp.prompt()`. Name, description, and schema come from the function. +* A URI with a `{param}` makes a resource **template**, listed separately from concrete resources. +* The server's **capabilities** are declared for you, and a client only asks for what a server declares. +* `Client(mcp)` connects to the server object in memory: your test harness from day one. + +Next up is **[Connect to a real host](real-host.md)**: this server inside Claude Desktop or an IDE, for real. Then **[Testing](testing.md)**: one page, one in-memory client, and you're never guessing whether it works. After that, each primitive gets its own page, starting with the one the model drives: **[Tools](../servers/tools.md)**. diff --git a/docs/get-started/index.md b/docs/get-started/index.md new file mode 100644 index 0000000..6a31769 --- /dev/null +++ b/docs/get-started/index.md @@ -0,0 +1,52 @@ +# Get started + +New to MCP, or new to this SDK? Start here. These pages take you from nothing to a +working, tested server: [install the SDK](installation.md), build your +[first server](first-steps.md), [connect it to a real host](real-host.md), and +[test it](testing.md) with an in-memory client. + +## Run the code + +All the code blocks can be copied and used directly: they are complete, working files. + +To follow along, paste a block into a `server.py` and open it in the MCP Inspector: + +```console +uv run mcp dev server.py +``` + +It is **HIGHLY encouraged** that you write (or copy) the code, edit it, and run it locally. Using it in your own editor is what really shows you the point: how little you write, the autocompletion, the type checks catching mistakes before you run anything. + +## You will not be guessing + +Every example in these docs is a complete file under [`docs_src/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/docs_src) in the SDK's own repository, and every one of them is exercised by the SDK's test suite through an **in-memory client**: + +```python +import pytest +from mcp import Client + +from server import mcp + + +@pytest.mark.anyio +async def test_add() -> None: + async with Client(mcp) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + assert result.structured_content == {"result": 3} +``` + +No subprocess, no port, no transport. `Client(mcp)` connects to the server object directly. + +If a change to the SDK breaks an example on one of these pages, CI goes red before the page does. The code you read here is the code that runs. + +You'll use this yourself in [Testing](testing.md); it's how you test your own servers, too. + +## Where to go next + +Once you have a server running, the rest of these docs are a reference, not a course. +Every page stands on its own, so jump straight to what you need: + +* What a server exposes (tools, resources, prompts) is **[Servers](../servers/index.md)**. +* What's available inside the functions you register is **[Inside your handler](../handlers/index.md)**. +* Getting it in front of clients (stdio, HTTP, your existing FastAPI app) is **[Running your server](../run/index.md)**. +* Building the other side, an application that *uses* MCP servers, is **[Clients](../client/index.md)**. diff --git a/docs/get-started/installation.md b/docs/get-started/installation.md new file mode 100644 index 0000000..4c26912 --- /dev/null +++ b/docs/get-started/installation.md @@ -0,0 +1,49 @@ +# Installation + +The Python SDK is on PyPI as [`mcp`](https://pypi.org/project/mcp/). It requires **Python 3.10+**. + +These docs describe **v2**, which is in beta, so the version pin is not optional yet: + +=== "uv" + + ```bash + uv add "mcp[cli]==2.0.0b1" + ``` + +=== "pip" + + ```bash + pip install "mcp[cli]==2.0.0b1" + ``` + +!!! warning "Why the pin" + Installers never select a pre-release unless you name one, so an unpinned `uv add "mcp[cli]"` + gives you the latest **v1.x** release, which these docs do not describe. Check the + [release history](https://pypi.org/project/mcp/#history) for the newest beta before you copy + the line above. + + The same applies to one-off commands: `uv run --with "mcp==2.0.0b1" ...`, not `uv run --with mcp ...`. + + If your *package* depends on `mcp`, add a `<2` upper bound (for example `mcp>=1.27,<2`) before + the stable v2 lands so the major version bump doesn't surprise you. + +## What gets installed + +You don't need to know any of this to use the SDK, but if you're wondering what each dependency is for: + +* `mcp-types`: every protocol type (requests, results, content blocks) as its own package, versioned in lockstep with the SDK. Every `from mcp_types import ...` in these docs is this package. +* [`anyio`](https://anyio.readthedocs.io/): the async runtime. The whole SDK is written against anyio, so it runs on either `asyncio` or `trio`. +* [`pydantic`](https://docs.pydantic.dev/): what every `mcp_types` model is built on, plus all schema generation and validation. +* [`pydantic-settings`](https://docs.pydantic.dev/latest/concepts/pydantic_settings/): server configuration via `MCP_*` environment variables and `.env` files. +* [`httpx`](https://www.python-httpx.org/) and [`httpx-sse`](https://pypi.org/project/httpx-sse/): the HTTP client behind the Streamable HTTP and SSE *client* transports. +* [`starlette`](https://www.starlette.io/), [`uvicorn`](https://www.uvicorn.org/), [`sse-starlette`](https://pypi.org/project/sse-starlette/), and [`python-multipart`](https://pypi.org/project/python-multipart/): the HTTP *server* transports. +* [`jsonschema`](https://pypi.org/project/jsonschema/): validates a tool's structured output against its declared output schema. +* [`pyjwt[crypto]`](https://pyjwt.readthedocs.io/): OAuth token handling for authorization. +* [`opentelemetry-api`](https://opentelemetry-python.readthedocs.io/): just the lightweight API, so the SDK's tracing middleware costs nothing unless you install an OpenTelemetry SDK and exporter yourself. +* [`typing-extensions`](https://typing-extensions.readthedocs.io/) and [`typing-inspection`](https://pypi.org/project/typing-inspection/): modern typing features on Python 3.10. +* [`pywin32`](https://pypi.org/project/pywin32/): Windows only, used for `stdio` subprocess management. + +## Optional extras + +* `mcp[cli]` adds [`typer`](https://typer.tiangolo.com/) and [`python-dotenv`](https://pypi.org/project/python-dotenv/) for the `mcp` command-line tool (`mcp dev`, `mcp run`, `mcp install`). You'll want this during development; you may not need it in a deployed server. +* `mcp[rich]` adds [`rich`](https://rich.readthedocs.io/) for nicer server logs. diff --git a/docs/get-started/real-host.md b/docs/get-started/real-host.md new file mode 100644 index 0000000..159c1f5 --- /dev/null +++ b/docs/get-started/real-host.md @@ -0,0 +1,182 @@ +# Connect to a real host + +A **host** is the application your server ends up inside: Claude Desktop, Claude Code, an IDE. The host is what the user talks to. Inside it, an MCP **client** launches your server as a child process and speaks to it over that process's stdin and stdout. + +Which means connecting to a host is one act: you tell it **the command that starts your server**. Everything on this page (two CLI commands, three JSON files) is a different place to put that same command. + +## One server, every host + +```python title="server.py" hl_lines="3 33-34" +--8<-- "docs_src/real_host/tutorial001.py" +``` + +Two tools and a resource, one file. Three things about that file matter to every host below: + +* `mcp.run()` with no arguments starts a **stdio** server: it blocks, reads protocol messages on stdin, and writes them on stdout. That is the transport every host on this page speaks. The host starts your file as a child process and owns those two pipes, which is why connecting is only ever "here is the command". You never pick a port, and nothing listens on one. +* `run()` is under `if __name__ == "__main__":`. Everything below **imports** this file rather than executing it, so an unguarded `run()` would start a server the moment anything loaded the module. +* The server object is a module-level global named `mcp`. That's the name `mcp run` looks for (`server` and `app` also work). Call it something else and you name it explicitly: `mcp run server.py:bookshop`. + +That is the last line of Python on this page. From here down it is all host configuration. + +## The launch command + +Every host below gets the same command: + +```bash +uv run --with "mcp[cli]==2.0.0b1" mcp run /absolute/path/to/server.py +``` + +One command for all of them because `uv run --with` resolves the pinned SDK into a fresh environment on the spot: it works from any directory, needs no project and no virtual environment to activate, and always gets the exact `mcp` version these docs describe. That matters here more than anywhere else, because a host launches your server from *its* working directory with a near-empty environment, not from your shell. + +It is also the command `mcp install` writes into Claude Desktop's config for you (below), so what you type by hand and what the tool generates agree. + +!!! warning "The version pin is not optional" + v2 of this SDK is in beta, and installers never select a pre-release unless you name one. An + unpinned `--with "mcp[cli]"` gives you the latest **v1.x**, which these docs do not describe. + Use the exact pin from **[Installation](installation.md)**. + +!!! tip "If a host can't find `uv`" + A host spawns your server with a minimal `PATH`, and `uv` may not be on it. Replace the bare + `uv` with the absolute path from `which uv` (macOS/Linux) or `where uv` (Windows). That is + exactly what `mcp install` writes. + +!!! note "This page is the local story" + Everything here runs your server on the machine the host is on: the host launches your + file, over stdio. That is exactly right for a personal or single-machine tool. To give a + server to people who do *not* have your file, you hand out a **URL**, not a command: the + same `mcp` object served over Streamable HTTP. **[Running your server](../run/index.md)** + is that decision in one table, and **[Deploy & scale](../run/deploy.md)** is the road from + there to a real hostname. + + And a host is nothing more than an application with an MCP client inside it, so your own + Python can play the host's part: **[Client transports](../client/transports.md)** launches + this same file as a subprocess with `stdio_client(...)`, and **[Testing](testing.md)** + connects to it in memory with no process at all. + +## Claude Desktop + +The one host the SDK can configure for you: + +```bash +uv run mcp install server.py +``` + +That's it. `mcp install` imports the file to read the server's name, finds Claude Desktop's config file, and writes the launch command into it. Along the way it converts your path to an absolute one, so you don't have to. + +There is nothing to be mystified by. This is the entry it writes: + +```json +{ + "mcpServers": { + "Bookshop": { + "command": "/absolute/path/to/uv", + "args": [ + "run", + "--frozen", + "--with", + "mcp[cli]==2.0.0b1", + "mcp", + "run", + "/absolute/path/to/server.py" + ] + } + } +} +``` + +That's the launch command from the section above with two additions: the absolute path to `uv`, and `--frozen` so `uv` never rewrites a lockfile it happens to be near. It lands in `claude_desktop_config.json`, which lives at: + +* **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +* **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + +You can write that file by hand. `mcp install` exists so you don't make the two classic mistakes (a relative path, a missing version pin) while doing it. + +Fully quit Claude Desktop (not just its window) and reopen it. + +!!! warning + `mcp install` fails with `Claude app not found` if Claude Desktop's config *directory* doesn't + exist yet. Install Claude Desktop and run it once: that's what creates the directory. + +!!! tip + Claude Desktop starts your server in its own process, so your shell's environment variables are + not there. `uv run mcp install server.py -v API_KEY=abc123` (or `-f .env`) records them in the + entry's `env` field. `--name` overrides the entry name; it defaults to the server's `name`. + +## Claude Code + +There is no file to edit. Register the server with the `claude` CLI; everything after `--` is the launch command. + +```bash +claude mcp add bookshop -- uv run --with "mcp[cli]==2.0.0b1" mcp run /absolute/path/to/server.py +``` + +Run `/mcp` inside a Claude Code session to confirm `bookshop` is connected and its tools are listed. + +## Cursor + +Create `.cursor/mcp.json` in your project root. + +```json +{ + "mcpServers": { + "bookshop": { + "command": "uv", + "args": ["run", "--with", "mcp[cli]==2.0.0b1", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +The same `command` plus `args`, under the same `mcpServers` key Claude Desktop uses. The server appears in Cursor's MCP settings with both tools listed. + +## VS Code + +Create `.vscode/mcp.json` in your project root. + +```json +{ + "servers": { + "bookshop": { + "type": "stdio", + "command": "uv", + "args": ["run", "--with", "mcp[cli]==2.0.0b1", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +Two differences from Cursor's file, and they are the only two: the wrapper key is `servers`, not `mcpServers`, and each entry declares its `type`. Confirm the trust prompt, then **MCP: List Servers** in the Command Palette shows `bookshop` running. + +!!! note + You need VS Code 1.99 or later with the **GitHub Copilot** extension signed in (Copilot Free is + enough), and Copilot Chat must be in **Agent** mode, because no other mode calls tools. + +## It doesn't show up + +Before you touch any host config, run the launch command yourself: + +```bash +uv run --with "mcp[cli]==2.0.0b1" mcp run /absolute/path/to/server.py +``` + +Nothing prints, and it doesn't return. That silence is correct: a stdio server is waiting for a host to speak first on stdin (`Ctrl-C` to stop it). A traceback or an immediate exit is the real bug, and now you can read it instead of guessing at it through a host. + +Once that command sits and waits, what's left is almost always one of three things: + +* **A relative path.** The host launches your server from *its* working directory, not the one you registered from. `server.py` where `/absolute/path/to/server.py` is needed is the single most common failure. If the host can't find `uv` either, that path has to be absolute too. +* **The host is still running its old config.** Hosts read their config at launch. Claude Desktop in particular has to be *fully quit* (not just its window closed) and reopened before an edit to `claude_desktop_config.json` takes effect. +* **Something reached stdout.** On stdio, stdout *is* the protocol. One stray `print()` and the host reads a corrupt message and drops the connection. Log with the `logging` module, which writes to stderr. **[Logging](../handlers/logging.md)** has the whole story. + +Claude Desktop keeps a log per server: `mcp-server-<NAME>.log` is your server's stderr, next to `mcp.log` for connections, under `~/Library/Logs/Claude` on macOS and `%APPDATA%\Claude\logs` on Windows. + +For anything past those three, **[Troubleshooting](../troubleshooting.md)** is the page. + +## Recap + +* A **host** (Claude Desktop, an IDE) runs an MCP client that launches your server as a child process over stdio. Connecting means giving it one launch command. +* That command is `uv run --with "mcp[cli]==2.0.0b1" mcp run /absolute/path/to/server.py`: version-pinned, no venv to activate, works from any directory. The pin is mandatory while v2 is in beta. +* **Claude Desktop** is the one host `mcp install` configures for you. It writes that same command (plus the absolute path to `uv`) into `claude_desktop_config.json`, so you never have to. +* **Claude Code** is `claude mcp add bookshop -- <launch command>`. **Cursor** is `.cursor/mcp.json` under `mcpServers`. **VS Code** is `.vscode/mcp.json` under `servers`, each entry with a `type`. +* Absolute paths everywhere, restart the host after editing its config, and never let anything but the SDK write to stdout. + +Every host on this page connected to the same file, with the same command. What that file can *expose* is the rest of these docs: **[Tools](../servers/tools.md)**, **[Resources](../servers/resources.md)**, and every transport besides stdio in **[Running your server](../run/index.md)**. diff --git a/docs/get-started/testing.md b/docs/get-started/testing.md new file mode 100644 index 0000000..82a113c --- /dev/null +++ b/docs/get-started/testing.md @@ -0,0 +1,107 @@ +# Testing + +The Python SDK ships a `Client` class with an **in-memory transport**: pass it your server object and it connects to it directly. + +No subprocess. No port. No transport at all. It's the same idea as FastAPI's `TestClient`. + +## Basic usage + +Let's assume you have a simple server with a single tool: + +```python title="server.py" +--8<-- "docs_src/testing/tutorial001.py" +``` + +To run the test below you'll need two extra (development) dependencies: + +=== "uv" + + ```bash + uv add --dev pytest inline-snapshot + ``` + +=== "pip" + + ```bash + pip install pytest inline-snapshot + ``` + +!!! info + These docs assume you already know [`pytest`](https://docs.pytest.org/en/stable/). + + [`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) is what the test below + uses to assert on the whole result object in one line. It records the output of a test as the + `snapshot(...)` literal you see. If you'd rather not use it, drop the import and assert on the + fields you care about (`result.content[0].text == "3"`) like in any other test. + +Now the test: + +```python title="test_server.py" +import pytest +from inline_snapshot import snapshot +from mcp import Client +from mcp_types import CallToolResult, TextContent + +from server import mcp + + +@pytest.fixture +def anyio_backend(): # (1)! + return "asyncio" + + +@pytest.fixture +async def client(): # (2)! + async with Client(mcp, raise_exceptions=True) as c: + yield c + + +@pytest.mark.anyio +async def test_call_add_tool(client: Client): + result = await client.call_tool("add", {"a": 1, "b": 2}) + assert result == snapshot( + CallToolResult( + content=[TextContent(type="text", text="3")], + structured_content={"result": 3}, + ) + ) +``` + +1. If you are using `trio`, return `"trio"` instead. See the [anyio documentation](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on) for the details. +2. The fixture yields a connected client. Every test that takes `client` gets a fresh in-memory connection to the same server. + +There you go! You can now extend your tests to cover more scenarios. + +## Why `raise_exceptions=True`? + +Two different things can go wrong, and this flag only touches one of them. + +An exception inside one of **your tools** is not a protocol failure. It becomes a normal result with +`is_error=True`, and the model reads the message. `raise_exceptions` doesn't change that: with or +without it, `call_tool` returns the same `is_error=True` result. There's a whole page on it: +**[Handling errors](../servers/handling-errors.md)**. + +A failure **outside** a tool body is different. On the connection `Client(mcp)` gives you, the +server sanitises it into a generic `"Internal server error"` before the client sees it. You should +never leak the details of an unexpected crash to a remote caller. In a test that is exactly what +you *don't* want, and it is what `raise_exceptions=True` changes: your test sees the real message +instead of the sanitised one. + +Leave it on in tests. It has no meaning in production code. + +## In-process by default + +!!! note + `Client(mcp)` connects in-process and is **era-neutral** by default: it probes the server and + picks the appropriate protocol path. Pin `mode="legacy"` if your test exercises legacy-specific + semantics (sampling or elicitation push, `message_handler`), and drop `raise_exceptions=True` + there: a legacy connection never sanitises in the first place, and the flag re-raises the + failure inside the server task instead of in your test. + +That one line is also why these docs can promise you that their examples work: every +example file is exercised by the SDK's own test suite, almost all of them through exactly this +client. You're using the same tool the SDK uses on itself. + +You have a working, tested server. Putting it inside a real application (Claude Desktop, an +IDE) is **[Connect to a real host](real-host.md)**; every other way to serve it is +**[Running your server](../run/index.md)**. diff --git a/docs/handlers/context.md b/docs/handlers/context.md new file mode 100644 index 0000000..f43521a --- /dev/null +++ b/docs/handlers/context.md @@ -0,0 +1,129 @@ +# The Context + +A tool's arguments come from the model. Everything else (the request you are serving, the server you live in, a way to talk back to the client) comes from one object: the **`Context`**. + +You don't construct it and you don't configure it. You ask for it. + +## Ask for it + +Add a parameter annotated with `Context` to any tool: + +```python title="server.py" hl_lines="2 8" +--8<-- "docs_src/context/tutorial001.py" +``` + +* The SDK builds a fresh `Context` for every request and passes it in. +* The parameter **name doesn't matter**. `ctx`, `context`, `c`: the SDK finds it by its annotation. +* Resources and prompts can declare one too, the same way. +* `ctx.request_id` is the id of the request your function is serving right now. + +!!! info + If you've used FastAPI, you've seen this move: declare a parameter with the framework's own type + (`Request` there, `Context` here) and the framework supplies it. Nothing to register, nothing to + configure: the type annotation is the whole mechanism. + +### Invisible to the model + +This is the part to internalise. Here is the input schema `tools/list` reports for `search_books`: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +One property. `ctx` is not an argument: it never appears in the schema, the model is never told about it, and no client can fill it in. It's a contract between you and the SDK, invisible on the wire. + +### Try it + +Run the server with the MCP Inspector: + +```console +uv run mcp dev server.py +``` + +The form for `search_books` has a single `query` field. Call it with `dune`: + +```text +[request 3] Found 3 books matching 'dune'. +``` + +The number is whichever request this happened to be. Call the tool again and it changes: every request gets its own `Context`. + +## What it gives you + +The injected object is small. Besides `request_id`: + +* `await ctx.read_resource(uri)`: read one of the server's **own** resources from inside a tool. The next section. +* `await ctx.report_progress(progress, total, message)`: stream progress back to the caller during a long call. The whole story is in **[Progress](progress.md)**. +* `await ctx.elicit(message, schema)` and `await ctx.elicit_url(...)`: pause the tool and ask the user a question. That's **[Elicitation](elicitation.md)**. +* `ctx.session`: the server's side of the conversation with this client. Notifications you send to the client live here; the last section uses it. +* `ctx.headers`: the request headers the transport carried, or `None` on stdio. Read a custom header with `(ctx.headers or {}).get("x-...")`. Headers are client-supplied input - fine for a locale or a feature flag, never an identity. +* `ctx.request_context`: the raw per-request record. The field you'll reach for is `lifespan_context`, the object your startup code yielded (see **[Lifespan](lifespan.md)**). + +Logging is deliberately not on that list. A server logs with Python's `logging` module, like any other Python program. **[Logging](logging.md)** is the short page on why. + +!!! tip + Injection only happens for the function you registered. A helper that your tool calls doesn't get + its own `Context`; pass `ctx` down as an ordinary argument. There is no ambient + "current context" to fetch from somewhere else. + +## Read your own resources + +A server's resources aren't only for clients. A tool can read them too: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/context/tutorial002.py" +``` + +`ctx.read_resource` resolves the URI through the same registry that serves `resources/read`, so a tool gets what a client would get: an iterable of `ReadResourceContents`, one per content block. For this URI there is one: + +```python +contents.content # 'fiction, non-fiction, poetry' +contents.mime_type # 'text/plain' +``` + +* `content` is exactly what `genres()` returned. One source of truth: the client browses the resource, your tools consume it, nobody copies the string. +* `describe_catalog`'s only parameter is the `Context`, so its input schema has **no properties at all**. The model calls it with `{}`. + +## Tell the client the list changed + +What a server offers is not fixed at import time. Register a tool at runtime, then tell the client: + +```python title="server.py" hl_lines="15-16" +--8<-- "docs_src/context/tutorial003.py" +``` + +* `mcp.add_tool(recommend_book)` registers a plain function as a tool: name, description and schema derived exactly as `@mcp.tool()` would have. +* `await ctx.session.send_tool_list_changed()` sends `notifications/tools/list_changed`. A client that receives it calls `tools/list` again and sees `recommend_book`. + +The siblings are `send_resource_list_changed()`, `send_prompt_list_changed()`, and `send_resource_updated(uri)` for a change to one specific resource. + +On a 2026-07-28 connection, clients receive change notifications only on a `subscriptions/listen` stream they opened, so the `send_*` methods above do not reach those streams. The `Context` publish methods deliver to every subscribed stream at once: `await ctx.notify_tools_changed()`, `await ctx.notify_prompts_changed()`, `await ctx.notify_resources_changed()`, and `await ctx.notify_resource_updated(uri)`. The whole story, including scaling out across replicas, is in **[Subscriptions](subscriptions.md)**. + +!!! check + Before anyone runs `enable_recommendations`, the tool you are promising does not exist. Call it + anyway and the result is an error the model can read: + + ```text + Unknown tool: recommend_book + ``` + + Run `enable_recommendations`, and the very same call succeeds. The tool list is genuinely + dynamic: `tools/list` reflects whatever is registered *right now*. + +## Recap + +* Annotate a parameter with `Context` (in a tool, a resource, or a prompt) and the SDK injects it. The name is yours. +* It is invisible to the model: the input schema only ever contains your real arguments. +* `ctx.request_id` identifies the request; `ctx.request_context.lifespan_context` is what your startup yielded. +* `await ctx.read_resource(uri)` lets a tool read the server's own resources. +* `ctx.session` is the channel back to the client: `send_tool_list_changed()` and its siblings tell it to re-fetch a list you changed. +* Progress reporting and elicitation also start at `Context`; each has its own page. + +Parameters the model never sees, filled by your own functions, are **[Dependencies](dependencies.md)**. diff --git a/docs/handlers/dependencies.md b/docs/handlers/dependencies.md new file mode 100644 index 0000000..509b263 --- /dev/null +++ b/docs/handlers/dependencies.md @@ -0,0 +1,158 @@ +# Dependencies + +A tool's arguments come from the model. Some values never should: a price looked up from your records, a confirmation only a person can give, anything the model could get wrong by inventing it. + +**Dependencies** are parameters filled by your own functions. You annotate the parameter, name the function, and the SDK calls it before your tool runs. + +## Declare one + +Wrap the parameter's type in `Annotated[...]` and add `Resolve(fn)`: + +```python title="server.py" hl_lines="18-19 23" +--8<-- "docs_src/dependencies/tutorial001.py" +``` + +* `check_stock` is a **resolver**: a plain function the SDK runs before `reserve_book`, whose return value becomes the `stock` argument. +* Its `title` parameter is the tool's own `title` argument, matched **by name**. The resolver sees exactly the validated value the tool body will see. +* The tool body starts from a `Stock` that already exists. No lookup code in the tool, no "what if it's missing" preamble. + +!!! info + If you've used FastAPI, this is `Depends`. Same move, same reason: the function declares what + it needs, the framework supplies it, and the wiring lives in the type annotation. + +### Invisible to the model + +Here is the input schema `tools/list` reports for `reserve_book`: + +```json +{ + "type": "object", + "properties": { + "title": {"title": "Title", "type": "string"} + }, + "required": ["title"], + "title": "reserve_bookArguments" +} +``` + +One property. Like the `Context` in **[The Context](context.md)**, a resolved parameter is a contract between you and the SDK: `stock` is not in the schema, the model is never told about it, and a client that sends a `stock` value anyway is ignored. The resolver's value is the only one your tool can receive. + +That last part is the point. A parameter the model cannot supply is a parameter the model cannot get wrong. + +### Try it + +Run the server with the MCP Inspector: + +```console +uv run mcp dev server.py +``` + +The form for `reserve_book` has a single `title` field. `stock` is nowhere on it. Call it with `Dune`: + +```text +Reserved 'Dune' (6 copies left). +``` + +The tool body never looked anything up: `check_stock` ran first, and the `Stock` it returned arrived as an argument. Try `Neuromancer` and the same resolver hands the tool a zero. + +!!! tip + You could just call `check_stock(title)` in the tool body. Declare it as a dependency when the + value deserves more than a helper call: every tool that needs stock declares the same parameter, + and the SDK runs the resolver at most once per call, no matter how many declare it. The next + sections add the rest: resolvers that depend on each other, and resolvers that ask the user. + +## Dependencies of dependencies + +A resolver can declare its own dependencies, with the same annotation: + +```python title="server.py" hl_lines="22 29-30" +--8<-- "docs_src/dependencies/tutorial002.py" +``` + +* `estimate_delivery` depends on `check_stock`. The SDK runs the graph in order: stock first, then the estimate, then the tool. +* Both `stock` and `delivery` ultimately need `check_stock`, but it runs **once per call**. One inventory lookup, two consumers. +* There is nothing to register. The graph *is* the annotations. + +!!! check + Don't take once-per-call on faith. Put a `print` in `check_stock` and call `order_book` from the + Inspector: one line per call. Two consumers, one lookup. + +The SDK analyses the graph when the tool is registered, not when it is called. A parameter it can't classify - not a `Context`, not a `Resolve(...)`, not a tool argument's name - and a cycle of resolvers both raise `InvalidSignature` at startup. Your server fails before a client ever connects, with the offending parameter or resolver named in the error. + +A resolver's parameters resolve exactly like a tool's: another `Resolve(...)`, the tool's own arguments by name, or the `Context` - `ctx.headers`, the lifespan object, all of it. + +!!! warning + On HTTP transports the `Context` includes `ctx.headers`. Headers are **client-supplied input**, + like any tool argument: fine for a locale or a feature flag, never an identity. Who the caller + is comes from your authorization layer (**[Authorization](../run/authorization.md)**), not from a header anyone can set. + +!!! tip + *Once per call* means exactly that: the next `tools/call` runs `check_stock` again. A resource + that should outlive a request - a database pool, an HTTP client - belongs in **[Lifespan](lifespan.md)**, and + a resolver can reach it through `ctx.request_context.lifespan_context`. + +## Ask when you must + +A resolver doesn't have to know the answer. It can return `Elicit(message, Model)` and the SDK asks the user - the **[Elicitation](elicitation.md)** machinery, run for you: + +```python title="server.py" hl_lines="26-32 39" +--8<-- "docs_src/dependencies/tutorial003.py" +``` + +* In stock: `confirm_backorder` returns a `Backorder` directly. **No question, no round-trip.** The user is only interrupted when their answer matters. +* Out of stock: the SDK sends the elicitation, validates the answer against `Backorder`, and injects it. Your resolver never touches the protocol. +* The tool reads `backorder.confirm` like any other argument. Answering **no** is still an answer: the elicitation is accepted with `confirm=False`, the tool runs, and no order is placed. Asking became a precondition, not plumbing in the tool body. + +And if the user won't answer at all - declines the question, or cancels it? + +!!! check + Run `order_book` for `Neuromancer` and decline the question. With the annotation written as + `Annotated[Backorder, Resolve(...)]` the tool body never runs; the call fails with an error + result the model can read: + + ```text + Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline + ``` + +That's the right default for a precondition: no answer, no order. When declining is an outcome your tool wants to handle - skip the backorder but still suggest another title - annotate `ElicitationResult[Backorder]` instead and the tool receives the full accept/decline/cancel outcome to branch on. **[Elicitation](elicitation.md)** shows that form, and everything else about asking: the schema rules, the three answers, the client's side of the conversation. + +!!! info + The framework picks the question's transport from the negotiated protocol version; the code + above is identical on both. On **2026-07-28** and later the question rides inside a + multi-round-trip `tools/call` - the server returns it, the client's `elicitation_callback` + answers it, and the `Client` retries the call for you (**[Multi-round-trip requests](multi-round-trip.md)**). On + **2025-11-25** and earlier it is a synchronous elicitation request mid-call. Each question is + asked exactly once per call - a guarantee about the question, not the resolver. In the + multi-round-trip form any resolver may run again whenever the call resumes after a question, + so code before a `return Elicit(...)` runs on each of those rounds; the recorded answer then + satisfies the repeated question without prompting the user again. A recorded answer is only + ever consulted when the resolver asks; a resolver that answers *without* asking, like + `check_stock`, always supplies its own computed value. Because each answer is matched back to + its question, an eliciting resolver must derive its question deterministically from the + tool's arguments and earlier answers. A per-call generated value (a `default_factory` id, a + timestamp) is re-derived on each round and must not appear in a question the answer is meant + to bind to. A question built from such volatile data makes every recorded answer look stale, + so the server re-asks it on every round until the client's round limit ends the call. + +## Ask the client, not the user + +Elicitation is one of the three questions a resolver can ask, and the multi-round-trip flow allows no others. The other two go to the **client** rather than the user: return `Sample(...)` to run an LLM call through the client (a `sampling/createMessage` request), or `ListRoots()` to fetch the client's current roots. Neither has an accept/decline outcome; the consumer annotates the result type directly, `CreateMessageResult` (`CreateMessageResultWithTools` when the request carries `tools` or `tool_choice`) or `ListRootsResult`: + +```python title="server.py" hl_lines="11-16 22" +--8<-- "docs_src/dependencies/tutorial004.py" +``` + +* The framework routes these exactly like `Elicit`: inside the multi-round-trip `tools/call` on **2026-07-28**, over the standalone server->client request on **2025-11-25**. An undeclared capability refuses the call with a `-32021` protocol error (`sampling`, `roots`, form-mode `elicitation`; `sampling.tools` when the request carries `tools` or `tool_choice`). +* Everything the info box above says about questions applies unchanged: a `Sample` request is matched to its recorded result by its exact rendering, so build it deterministically from the tool's arguments and earlier answers; the client then pays for the LLM call once per tool call, not once per round. The recorded result rides `request_state` for the rest of the call, so a very large completion makes every remaining round-trip heavier. +* The standalone sampling and roots *features* are deprecated at 2026-07-28 (SEP-2577). New servers that need the client's model ask through this carrier; servers that don't should integrate with an LLM provider directly. `include_context` values other than `"none"` are themselves deprecated; avoid them. + +## Recap + +* `Annotated[T, Resolve(fn)]` on a tool parameter: the SDK runs `fn` and injects its return value. +* A resolved parameter is invisible to the model and cannot be supplied by a client. Values the model must not invent - prices, identities, permissions - belong here. +* A resolver's parameters are resolved the same way: the `Context`, another `Resolve(...)`, or a tool argument by name. The graph runs each resolver at most once per round, however many consumers it has; each question is asked exactly once, and any resolver may run again when a call resumes after a question. +* Bad graphs fail at registration with `InvalidSignature`, not mid-call. +* Return `Elicit(message, Model)` to ask the user, only when you have to. Unwrapped annotations abort on decline; `ElicitationResult[T]` lets the tool branch. +* Return `Sample(...)` or `ListRoots()` to ask the client for an LLM completion or the roots list; the plain result is injected. + +The state your server builds once at startup, and how a handler reaches it, is the **[Lifespan](lifespan.md)** page. diff --git a/docs/handlers/elicitation.md b/docs/handlers/elicitation.md new file mode 100644 index 0000000..3f3f5a6 --- /dev/null +++ b/docs/handlers/elicitation.md @@ -0,0 +1,185 @@ +# Elicitation + +A tool that is halfway through its job and missing one answer doesn't have to fail. + +**Elicitation** lets it ask. In the middle of a tool call the user gets a question, and their answer comes back into the same function call. + +There are two modes: + +* **Form mode**: you need a value (a confirmation, a date, a quantity). You describe the fields, the client renders the form. +* **URL mode**: you need the user to go somewhere else (an OAuth consent screen, a payment page). Nothing they do there passes through the protocol. + +And there are two ways to ask. The one to reach for is a **resolver**: you hang the question on a parameter, and the SDK asks - on any connection, whatever protocol era the client speaks. The direct way, `await ctx.elicit(...)`, is a request from the *server* to the *client*, a channel that only exists for a client on a legacy connection (spec version 2025-11-25 or earlier). Both are on this page; start with the resolver. + +## Ask with a resolver + +A question that gates the whole tool - *are you sure? which of the three matching accounts?* - can be lifted out of the tool body into a **resolver**, and the framework asks it for you. + +A parameter annotated `Annotated[T, Resolve(fn)]` is filled by running `fn` before the tool body. The resolver returns the value directly when it already knows it, or returns `Elicit(...)` to have the framework ask: + +```python title="server.py" hl_lines="24-30 35-36" +--8<-- "docs_src/elicitation/tutorial004.py" +``` + +* `confirm_delete` reads the tool's own `path` argument by name, lists the folder, and **only elicits when it must** - an empty folder resolves to `Confirm(ok=True)` with no round-trip to the client. +* `delete_folder` annotates `ElicitationResult[Confirm]`, so the framework injects the whole outcome and the tool `match`es every case: accept-and-confirm, accept-but-keep (`ok=False`), decline, cancel. +* The `confirm` parameter never appears in the tool's input schema - the client supplies `path`, the resolver supplies `confirm`. + +Annotate the unwrapped model (`Annotated[Confirm, Resolve(confirm_delete)]`) instead when the tool doesn't need to branch: it receives the model on accept and the call aborts with an error on decline or cancel. + +A resolver works on **every** connection. For a client on a legacy connection the SDK sends it the question directly; on a **2026-07-28** connection the SDK *returns* the question from the call, and the client's next attempt carries the answer. Your resolver never knows the difference; what happens underneath is **[Multi-round-trip requests](multi-round-trip.md)**. + +Asking is only one thing a resolver can do. The general mechanism - dependencies that compute without asking, dependencies of dependencies, what the model can and cannot supply - is the **[Dependencies](dependencies.md)** page. + +## Ask from inside the tool + +A tool can also stop in the middle of its own body and ask. + +!!! warning + `ctx.elicit()` and `ctx.elicit_url()` are requests from the *server* to the *client* - a + channel that only exists for a client on a legacy connection (spec version **2025-11-25** + or earlier). On a **2026-07-28** connection there are no server-initiated requests, so + these calls fail. A resolver works on both. **[Protocol versions](../protocol-versions.md)** + has the whole story. + +`await ctx.elicit()` takes a message and a Pydantic model: + +```python title="server.py" hl_lines="9-11 20-23 25" +--8<-- "docs_src/elicitation/tutorial001.py" +``` + +* The **`Context`** parameter is what gives you `ctx.elicit`; any tool can take one. That object has its own page: **[The Context](context.md)**. +* `AlternativeDate` is the **schema** of the answer you want. +* The tool is `async def`. It has to be: it stops in the middle and waits for a person. +* On any other date the tool returns straight away. It only asks when it has to. +* The date the user accepts goes back through `book_table` itself. An answer is input like any other: an alternative that is also fully booked gets asked about again, not confirmed blind. + +### What the client receives + +The client gets your message and, next to it, a JSON Schema generated from the model: + +```json +{ + "properties": { + "accept_alternative": { + "description": "Try another date?", + "title": "Accept Alternative", + "type": "boolean" + }, + "date": { + "default": "2025-12-26", + "description": "Alternative date (YYYY-MM-DD)", + "title": "Date", + "type": "string" + } + }, + "required": ["accept_alternative"], + "title": "AlternativeDate", + "type": "object" +} +``` + +That schema is the form. `Field(description=...)` is the label; a default pre-fills the input and makes the field optional. It's the same Pydantic-to-JSON-Schema machinery **[Tools](../servers/tools.md)** describes for a tool's arguments. + +!!! warning + An elicitation schema is not as expressive as a tool's input schema. Flat, primitive fields + only: `str`, `int`, `float`, `bool`, or a `Literal` of strings (it becomes an `enum`). + Put a model inside the model and `ctx.elicit` raises before anything is sent to the client: + + ```text + TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition + ``` + + You are interrupting a person mid-task. If the answer needs nesting, it should have been an + argument to the tool. + +### The three answers + +`result.action` tells you what the user did, and there are exactly three possibilities: + +* `"accept"`: they submitted the form. `result.data` is an `AlternativeDate` instance, already validated. +* `"decline"`: they said no. +* `"cancel"`: they dismissed the question without choosing. + +`result.data` only exists on `"accept"`, which is why the example checks `result.action` first. Your type checker enforces the order: after `result.action == "accept"`, `result.data` is an `AlternativeDate`; before it, there is no `.data` at all. + +A refusal is not an error. The tool decides what declining means (here, no booking) and answers the model normally. + +!!! tip + The answer is validated against your model before your code sees it. A client that sends + `"maybe"` for a `bool` doesn't corrupt your booking: the call fails with a + schema-mismatch error, your `if` never runs. + +## Send the user to a URL + +Some things must not go through the model or the client: credentials, card numbers, OAuth consent. For those you don't ask for data; you ask the user to go somewhere: + +```python title="server.py" hl_lines="10-14 23" +--8<-- "docs_src/elicitation/tutorial002.py" +``` + +* `ctx.elicit_url()` takes the message, the **URL** to visit, and an `elicitation_id` you choose: any string that identifies this elicitation within your server. +* The result has an action and nothing else. `"accept"` means the user agreed to open the URL, **not** that they finished what's on the other side. +* The payment happens out of band, between the user's browser and your payment provider. No content ever comes back through MCP. + +Look at the second tool. When your server learns the out-of-band flow finished (a webhook, a poll; here it's modelled as a second tool), `ctx.session.send_elicit_complete(...)` sends `notifications/elicitation/complete` with the same `elicitation_id`. That is how the client knows it can stop showing *"waiting for payment..."*. Without it, the client can only guess. + +## The client side + +Servers ask. Clients answer by passing an **`elicitation_callback`** to `Client(...)`: + +```python title="client.py" hl_lines="7-8 19" +--8<-- "docs_src/elicitation/tutorial003.py" +``` + +* One callback handles both modes. `params` is a union of `ElicitRequestFormParams` and `ElicitRequestURLParams`; `isinstance` is the branch. +* For a URL, you show `params.url` to the user and return the action they chose. Never any `content`. +* For a form, a real application renders `params.requested_schema` and returns the user's input as `content`. This one always says yes with a canned answer, which is exactly the callback you want in a test. +* Passing the callback is also the **capability declaration**: it's how the server learns this client can be asked. The other things a client can answer for a server live in **[Client callbacks](../client/callbacks.md)**. + +!!! info + Elicitation is a request from the *server* to the *client*, and those only exist on a + classic-handshake session, which is why this client passes `mode="legacy"`. + On a **2026-07-28** connection a tool asks by *returning* the question from the call + instead; that flow is **[Multi-round-trip requests](multi-round-trip.md)**. + +### Try it + +Start the `ctx.elicit` form-mode `server.py` (the `book_table` one) on Streamable HTTP (**[Running your server](../run/index.md)** has the one-liner), then run the client's `main()` and ask `book_table` for Christmas day. + +The callback prints the question it was sent: + +```text +No tables for 2 on 2025-12-25. Would you like to try another date? +``` + +It answers with `{"accept_alternative": True, "date": "2025-12-27"}`, and the tool, which has been waiting inside `await ctx.elicit(...)` this whole time, finishes the booking: + +```text +Booked a table for 2 on 2025-12-27. +``` + +Now swap in the URL-mode `server.py` and point the same `main()` at `pay_deposit`: the same callback takes the other branch, prints the payment link, and the tool comes back with *"Complete the payment in your browser."* One round trip, mid-call, in both directions. + +!!! check + Now remove `elicitation_callback=` from the `Client` and call `book_table` for Christmas day + again. The whole call fails with a protocol error: + + ```text + Elicitation not supported + ``` + + A client that registered no callback never declared the `elicitation` capability, so there is + nobody to ask. Your tool didn't get a `"decline"`; it got an exception. Design for it: every + elicitation needs a sensible answer to "what if I can't ask?". + +## Recap + +* A parameter annotated `Annotated[T, Resolve(fn)]` is filled by a resolver, which returns `Elicit(...)` when it has to ask. It works on every connection. +* The schema is a flat Pydantic model: primitive fields only, validated on the way back. +* `result.action` is `"accept"`, `"decline"` or `"cancel"`; `result.data` exists only on accept. +* `await ctx.elicit(message, schema=Model)` asks from inside the tool body, and `await ctx.elicit_url(message, url, elicitation_id)` is for everything that must not pass through the model (`ctx.session.send_elicit_complete(elicitation_id)` says the out-of-band part is done). Both are server-to-client requests: they need the client on a legacy connection. +* The client answers with one `elicitation_callback`, branching on the params type; registering it is what declares the capability. +* On a 2026-07-28 connection the server returns the question instead of pushing it; the same callback is fed by **[Multi-round-trip requests](multi-round-trip.md)**. + +Everything underneath that return (the retry loop, protecting `requestState`, driving it yourself) is **[Multi-round-trip requests](multi-round-trip.md)**. diff --git a/docs/handlers/index.md b/docs/handlers/index.md new file mode 100644 index 0000000..daf9fde --- /dev/null +++ b/docs/handlers/index.md @@ -0,0 +1,31 @@ +# Inside your handler + +A handler's arguments come from the client. Everything *else* it can read, and +everything it can do while it runs, is here. + +What it can read: + +* **[The Context](context.md)** is the one extra parameter any handler can + ask for: the live request, its headers, its session, and the progress and + change-notification verbs. +* **[Dependencies](dependencies.md)** are parameters the model never sees, + filled in by your own functions with `Resolve`. +* **[Lifespan](lifespan.md)** covers state your server builds once at + startup, and how a handler reaches it through the `Context`. + +What it can do while it runs: + +* Ask the user for more input with **[Elicitation](elicitation.md)**, and + **[Multi-round-trip requests](multi-round-trip.md)**, the 2026-07-28 + pattern that carries it. +* Ask the client for an LLM completion or its workspace folders with + **[Sampling and roots](sampling-and-roots.md)**, deprecated but still + served. +* Report **[Progress](progress.md)** on something slow. +* Write logs (to standard error, for whoever operates the server) with + **[Logging](logging.md)**. +* Tell subscribed clients that something changed with + **[Subscriptions](subscriptions.md)**. + +If you haven't registered a handler yet, start with +**[Tools](../servers/tools.md)**. Every page here assumes you have one. diff --git a/docs/handlers/lifespan.md b/docs/handlers/lifespan.md new file mode 100644 index 0000000..35b9bd0 --- /dev/null +++ b/docs/handlers/lifespan.md @@ -0,0 +1,102 @@ +# Lifespan + +Most real servers hold something for their whole life: a database pool, an HTTP client, a loaded model. + +You don't want to build it on every call, and you do want to close it cleanly. That's what the **lifespan** is for. + +## A typed lifespan + +A lifespan is an `@asynccontextmanager` that receives the server and `yield`s **one object**. Whatever you yield is available to every handler for as long as the server runs. + +```python title="server.py" hl_lines="25-31 34 38 40" +--8<-- "docs_src/lifespan/tutorial001.py" +``` + +Read it bottom-up: + +* `app_lifespan` connects the `Database` **before** the `yield` and disconnects it **after**, in a `finally`. That's startup and shutdown. +* It yields an `AppContext`, a plain dataclass holding the things you set up. One field today, ten tomorrow. +* `MCPServer("Bookshop", lifespan=app_lifespan)` is the whole wiring. +* Inside the tool, the yielded object is `ctx.request_context.lifespan_context`. + +The lifespan runs **once**. It is entered when the server starts (before the first request) and exited when the server stops. Every request in between shares the same `AppContext`. + +!!! info + If you've written a FastAPI `lifespan`, you already know this. Same decorator, same `yield`, same `finally`. + +### What the model sees + +Nothing new. `ctx` is a **Context** parameter, so the SDK injects it and it never reaches the input schema: + +```json +{ + "type": "object", + "properties": { + "genre": {"title": "Genre", "type": "string"} + }, + "required": ["genre"], + "title": "count_booksArguments" +} +``` + +`genre` is the only argument the model can pass. The lifespan is your server's business. + +`@mcp.resource()` and `@mcp.prompt()` functions can take a `ctx` parameter too, written as a bare `Context` for a reason the next section gets to. Everything `ctx` carries is in **[The Context](context.md)**. + +### It really is typed + +Look at the annotation again: `ctx: Context[AppContext]`. + +That one type parameter is why `ctx.request_context.lifespan_context` **is** an `AppContext` to your type checker. `.db` autocompletes; `.dbb` is an error before you ever run the server. + +Write a bare `Context` instead and `lifespan_context` is typed as `dict[str, Any]`: the type checker has no way to know what your lifespan yielded. The object is still there at runtime; you've lost the help. + +!!! warning + `Context[AppContext]` is a **tool-only** spelling. Put it on an `@mcp.resource()` or + `@mcp.prompt()` function and every call to that handler fails. The client gets an error back, + and the server log shows why: + + ```text + Context is not available outside of a request + ``` + + In resources and prompts, write the bare `ctx: Context`. The object your lifespan yielded is + still `ctx.request_context.lifespan_context` at runtime; you give up the type parameter, not + the object. + +!!! tip + There is always a lifespan. If you don't pass one, the SDK's default yields an empty `dict`, + so `ctx.request_context.lifespan_context` is `{}`, never `None`. That default is also why a + bare `Context` types it as `dict[str, Any]`. + +## Watch it happen + +"Startup runs before the first request" is the kind of sentence you should not have to take on faith. + +Strip the server down to the lifecycle: give `Database` a `connected` flag, flip it in `connect()` and `disconnect()`, and add a tool that reports it. + +```python title="server.py" hl_lines="11 14 17 25 44" +--8<-- "docs_src/lifespan/tutorial002.py" +``` + +`database` lives at module level for one reason: so you can look at it from *outside* the server. + +!!! check + Three moments, three values: + + * Before the server starts, `database.connected` is `False`. Importing the module connected nothing. + * While it's running, call `database_status` and the result is `"connected"`. + * Stop the server and the `finally` block runs: `database.connected` is `False` again. + + The work happened exactly where you put it: around the `yield`, not at import time and not per request. + +## Recap + +* `lifespan=` takes an `@asynccontextmanager` that receives the server and `yield`s one object. +* Code before the `yield` is startup. The `finally` after it is shutdown. +* It runs once, around the whole life of the server, not per request. +* Whatever you `yield` is `ctx.request_context.lifespan_context` in every tool, resource, and prompt. +* `ctx: Context[AppContext]` makes that access fully typed in tools. Resources and prompts take the bare `Context`. +* No `lifespan=` means an empty `dict`, never `None`. + +A handler that stops mid-call to ask the user for something only they know is **[Elicitation](elicitation.md)**. diff --git a/docs/handlers/logging.md b/docs/handlers/logging.md new file mode 100644 index 0000000..945aa60 --- /dev/null +++ b/docs/handlers/logging.md @@ -0,0 +1,78 @@ +# Logging + +Log from a tool the way you log from any other Python function: with the standard library. + +MCP has a protocol-level **logging capability**: a server could push its log messages to the client as notifications, through methods on the `Context` object. The 2026-07-28 revision of the spec **deprecates that capability and does not replace it**, so these docs don't teach it. The full list of what's deprecated and what to do instead is in **[Deprecated features](../deprecated.md)**. + +What you do instead is what you do in every other Python program: the standard library. + +## A tool that logs + +```python title="server.py" hl_lines="1 5 13" +--8<-- "docs_src/logging/tutorial001.py" +``` + +* `logging.getLogger(__name__)` gives you a logger named after your module. Create it once, at the top. +* Inside the tool you call `logger.info(...)` like in any other function. Nothing to inject, nothing to `await`, nothing MCP-specific. + +!!! check + Call the tool and look at the whole result: + + ```python + result.content # [TextContent(text="Found 3 books matching 'dune'.")] + result.structured_content # {'result': "Found 3 books matching 'dune'."} + ``` + + The log line is nowhere in it. Logging is for **you**, the person operating the server. The model + never sees it. If the model should read something, `return` it. + +## Where it goes + +For a **stdio** server this question matters more than usual. The host launched your server as a subprocess and is reading MCP messages from its **stdout**. Standard error is yours. + +The standard library already does the right thing: log output goes to `sys.stderr` by default. Your `logger.info(...)` lines land in the terminal (or wherever the host collects the subprocess's stderr), and the protocol stream stays clean. + +!!! tip + Never `print()` in a stdio server. `print` writes to **stdout**, and stdout *is* the wire: one stray + line and the client is trying to parse it as JSON-RPC. + + `logger.debug("got here")` is the same one line of effort and goes to the right place. + +## The level + +You don't have to call `logging.basicConfig()` yourself. Constructing an `MCPServer` already did, with a handler pointed at standard error, at the level you pass as `log_level=`, so `MCPServer("Bookshop", log_level="DEBUG")` is all it takes to see your `logger.debug(...)` lines. + +The default is `"INFO"`. + +`logging.basicConfig()` never replaces handlers that already exist. If you configure logging yourself before creating the server, your configuration wins. + +## Try it + +Run the server with the MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Call `search_books` from the **Tools** tab. The Inspector shows you the result: only the return value. The line + +```text +Searching for 'dune' +``` + +went to standard error: the terminal, not the wire. + +!!! info + If what you actually want is *tracing* (every request, how long it took, whether it failed), you + don't want log lines, you want spans. Your server already emits them: the SDK traces every + message with OpenTelemetry out of the box. See **[OpenTelemetry](../run/opentelemetry.md)**. + +## Recap + +* The MCP protocol's logging capability is deprecated by the 2026-07-28 spec and not replaced. Don't build on it. +* `logger = logging.getLogger(__name__)` at module level, `logger.info(...)` in the tool. That's the whole pattern. +* Log output never reaches the model. Only the value you `return` does. +* Standard error is yours; stdout belongs to the protocol. Never `print()` in a stdio server. +* `MCPServer(..., log_level="DEBUG")` sets the level, and a logging configuration you made first is left alone. + +Telling connected clients that something on your server changed (the tool list, a resource) is **[Subscriptions](subscriptions.md)**. diff --git a/docs/handlers/multi-round-trip.md b/docs/handlers/multi-round-trip.md new file mode 100644 index 0000000..e089034 --- /dev/null +++ b/docs/handlers/multi-round-trip.md @@ -0,0 +1,186 @@ +# Multi-round-trip requests + +Sometimes a tool can't finish in one round trip. It needs something only the user has: a choice, a confirmation, a credential. + +Before 2026-07-28 the server got it by calling **back**: opening its own request to the client (an elicitation, a sampling call) in the middle of handling the original one. The 2026-07-28 spec retires that back-channel. + +Instead, the server **returns**. + +## Return, don't call back + +The server answers `tools/call` with an **`InputRequiredResult`** instead of a `CallToolResult`. Two of its fields do the work: + +* **`input_requests`**: what the server still needs, as a dict keyed by names the server chose. Each value is an `ElicitRequest`, a `CreateMessageRequest`, or a `ListRootsRequest`. +* **`request_state`**: an opaque token. The client echoes it back verbatim on the retry. Your server is the only thing that reads it. + +The client fulfils each request, then calls the **same tool again**, carrying its answers in `input_responses` and the token in `request_state`. The server now has what it was missing and returns a normal `CallToolResult`. + +That's the whole protocol. Every leg is an ordinary request from the client to the server. Nothing ever flows the other way. + +## The server side + +On `@mcp.tool()` you rarely build this by hand: declare a dependency that asks the user (`Elicit`), samples the client's LLM (`Sample`), or lists its roots (`ListRoots`) and the SDK returns the `InputRequiredResult` for you; that form is the **[Dependencies](dependencies.md)** page. The two forms don't mix: a call has one `input_responses`/`request_state` channel, so a tool that uses `Resolve(...)` parameters cannot also return `InputRequiredResult` from its body. A declared `InputRequiredResult` return is rejected at registration (`InvalidSignature`), and an undeclared one fails the call at runtime. The manual form is the **low-level** `Server`, whose `on_call_tool` handler is allowed to return either result type: + +```python title="server.py" hl_lines="44-47" +--8<-- "docs_src/mrtr/tutorial001.py" +``` + +* `on_call_tool` is typed `-> CallToolResult | InputRequiredResult`. Returning the second one is the entire server-side API. +* On the first call `params.input_responses` is `None`, so the guard fires and the handler asks instead of answering. +* On the retry, the `ElicitResult` the client sent is sitting under the **same key** (`"region"`) that the server used in `input_requests`. + +Everything else in that file (the explicit `input_schema`, the hand-built `CallToolResult`) is the ordinary low-level `Server`, covered in **[The low-level Server](../advanced/low-level-server.md)**. This page only adds the second return type. + +## Beyond tools + +`tools/call` is not special: at 2026-07-28 a server may answer `prompts/get` and `resources/read` the same way. On `MCPServer`, an `@mcp.prompt()` function — or an `@mcp.resource()` **template** function — returns the `InputRequiredResult` itself and reads the retry's answers off the context: + +```python title="server.py" hl_lines="21 23 25" +--8<-- "docs_src/mrtr/tutorial004.py" +``` + +* The first round returns the `InputRequiredResult`. On the retry, `ctx.input_responses` holds the answers under the same keys and the function returns its ordinary result — prompt messages here, resource content for a template resource. +* A `request_state` you set is sealed before it crosses the wire and verified on the echo, like everything else on the server; **[Protecting `requestState`](#protecting-requeststate)** below covers what the seal gives you and when you need to configure keys. +* An `@mcp.tool()` function can return the result directly the same way, when the dependency form doesn't fit. +* Static `@mcp.resource()` functions don't participate: they take no `Context`, so they could never read the retry. Only template resources can ask. +* The era rules below apply unchanged: returning an `InputRequiredResult` on a pre-2026 session is the same `-32603` the warning describes. + +## The client side + +`Client` runs the loop for you. + +Register the callbacks the server might ask for (`elicitation_callback`, `sampling_callback`, `list_roots_callback`) and call the tool. When an `InputRequiredResult` arrives, `Client` dispatches each entry in `input_requests` to the matching callback, retries with the answers and the echoed `request_state`, and keeps going until a `CallToolResult` comes back: + +```python title="client.py" hl_lines="12 13" +--8<-- "docs_src/mrtr/tutorial003.py" +``` + +* That `elicitation_callback` is the same one a pre-2026 server's back-channel `elicitation/create` would have hit. The same is true of `sampling_callback` for `sampling/createMessage` and `list_roots_callback` for `roots/list`: at 2026-07-28 the standalone server->client RPCs are gone, but the identical `ElicitRequest` / `CreateMessageRequest` / `ListRootsRequest` payloads ride inside `input_requests` and dispatch to the same three callbacks. One set of callbacks serves both eras. +* `call_tool` returns a plain `CallToolResult`. The intermediate rounds are invisible to the caller. +* `get_prompt` and `read_resource` drive the same loop. + +!!! check + Leave the callback off and the loop fails on the first round: the SDK's stand-in callback + answers every elicitation with an error, and `call_tool` raises `MCPError` with the message + *"Elicitation not supported"*. + +The loop is bounded. `Client(..., input_required_max_rounds=10)` is the default cap; a server that keeps returning `InputRequiredResult` past it makes `call_tool` raise. If a round carries only `request_state` and no `input_requests`, `Client` sleeps briefly (50ms doubling to a 250ms ceiling) before retrying, so a server that is just saying *"not done yet"* isn't busy-polled. + +### Driving the loop yourself + +The auto-loop is enough for a single-process client. Own the loop instead when: + +* Your client is **distributed**: the process that renders the question to the user is not the process that called `call_tool`, so a different worker issues the retry. `request_state` is the persistable token you carry across that boundary, through your own storage, and `input_responses` is what the other side sends back with it. +* You want to **inspect** each round: log or audit every `input_requests` entry, refuse certain request kinds, or apply your own backoff between legs. +* You want a **wall-clock** bound rather than a round-count bound: wrap your own loop in `anyio.fail_after(...)` instead of relying on `input_required_max_rounds`. + +Drop to the underlying session, where `allow_input_required=True` hands you the union directly: + +```python title="client.py" hl_lines="13 14 20" +--8<-- "docs_src/mrtr/tutorial002.py" +``` + +* `client.session.call_tool(..., allow_input_required=True)` widens the return type to `CallToolResult | InputRequiredResult`. The `isinstance` is what narrows it back. +* `request_state` is now in your hands. Write it down between legs and the conversation can resume from a fresh process. +* For every entry in `input_requests` you put an `InputResponse` under the **same key** in `input_responses`. `fulfil` is where your UI goes; this one hard-codes the answer. +* Same tool name, same `arguments`, every leg. The retry is the original call carried out again, not a new method. + +## Protecting `requestState` + +Everything above treats `request_state` as an echo, and on the wire that is all it is. But the client holds it between legs (writing it down across processes is exactly what the previous section blessed), so what comes back is **client-supplied input**: it can be modified, expired, or lifted from a different call entirely. The spec requires servers to integrity-protect this state and reject the round when verification fails, whenever the state can influence authorization, resource access, or business logic. + +`MCPServer` protects it by default. Every server seals outgoing `requestState` and verifies every echo — resolver state and hand-built state alike — under a key generated at process start. You configure nothing, write plaintext, and read plaintext; the wire only ever carries an opaque encrypted token. + +The default key lives and dies with the process, which is the one thing you must know before deploying beyond a single process: + +```python +from mcp.server.mcpserver import MCPServer, RequestStateSecurity + +# Multi-instance or restart-surviving: one or more shared secret keys (>= 32 bytes each). +mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key])) +``` + +* **The default (no configuration)** suits a single process: stdio, or exactly one HTTP worker. A retry that lands on a different worker, a different instance behind a load balancer, or the same server after a restart is sealed under a key that process doesn't have — the client gets the frozen rejection below and must start the flow over. +* **`keys=[...]`** is required whenever a retry can reach a **different instance** (multi-worker `uvicorn`, load-balanced HTTP) or must survive restarts: every instance verifies what any sibling minted. Same machinery, your secret instead of a generated one. +* For your own crypto, such as a KMS or an existing token service, pass `RequestStateSecurity(codec=...)` instead of `keys`; **[Bring your own crypto](#bring-your-own-crypto)** below covers the contract. + +### What the seal carries + +Default or configured, `requestState` on the wire is an encrypted, authenticated token. Your code never sees it: handlers and resolvers write plaintext and read plaintext (`ctx.request_state`); the SDK seals on the way out and verifies on the way in. Beyond integrity, each token is bound to: + +* **A time window.** Every round re-seals with a fresh expiry, so `RequestStateSecurity(ttl=...)` (default 600 seconds) bounds per-round think time, not the whole flow. +* **The authenticated principal.** When the request carries an OAuth access token the SDK validated, the state is bound to the token's client, issuer, and subject: state minted for one user fails under another, even when both users share one OAuth client. A verifier that supplies no subject degrades the binding to the client identity alone, which under URL-based client IDs is shared by every user of that client software. When auth is terminated outside the SDK (a fronting proxy), or the transport is unauthenticated, there is no principal to bind and this check is inert, unless `RequestStateSecurity(bind_principal=...)` supplies one from your own identity signal. Whichever components your token verifier supplies, it must supply them consistently: a verifier that includes the subject on some requests and omits it on others changes the principal mid-flow, and in-flight rounds are rejected. +* **The originating request.** The method, the tool or prompt name (or resource URI), and a digest of the arguments. A token replayed against a different tool, different arguments, or a different method fails. +* **The exact question asked.** Every resolver answer is pinned to the rendered question the client was shown, both on the round it first arrives and when a recorded answer is reused later. Redeploy with a reworded message or a changed schema and the server re-asks instead of consuming a stale answer. The same pinning cuts the other way: derive messages from the tool's arguments, not from per-call data. A message built from a timestamp or a live rate renders differently every round, so every recorded answer looks stale and the server re-asks until the client's round limit ends the call. + +All of that is the SDK's job, not yours, and not the codec's if you bring your own. + +### Rotating keys + +`keys[0]` seals new state; every key in the list verifies. Zero-downtime rotation is three phases, each fully rolled out before the next: + +```python +RequestStateSecurity(keys=[OLD, NEW]) # 1: every instance learns to verify NEW; OLD still mints +RequestStateSecurity(keys=[NEW, OLD]) # 2: NEW mints; in-flight OLD state keeps verifying +RequestStateSecurity(keys=[NEW]) # 3: one ttl after phase 2 is fully out, retire OLD +``` + +Never promote the minter first: minting under a key some instance can't yet verify drops in-flight rounds mid-rollout. + +Keys are scoped to one service. The sealed envelope also carries the server's name as an audience claim, so a token minted by a different service that happens to share a secret is rejected anyway. The claim is only as distinctive as the name, so a server given an explicit policy must have a real name or set `RequestStateSecurity(audience=...)` — an unnamed one raises at construction. `audience=` also serves deliberate multi-service topologies where one service must accept state another minted. (The no-configuration default is exempt: its key never leaves the process, so the audience claim has nothing to add.) + +### Bring your own crypto + +`RequestStateSecurity(codec=...)` takes anything with `seal(bytes) -> str` and `unseal(str) -> bytes` that raises `InvalidRequestState` for any token it did not mint. The classic shape is envelope encryption against a KMS, where you unwrap a data key once at startup and keep the per-token crypto local: + +```python title="server.py" hl_lines="12 26-27 34-35 38" +--8<-- "docs_src/mrtr/tutorial005.py" +``` + +TTL, principal binding, and request binding are **not** the codec's job: the SDK stamps them into the payload before `seal` and re-verifies them after `unseal`, for every codec. A codec's only obligations are integrity (tampered means raise) and, ideally, confidentiality. + +### When verification fails + +Every inbound failure, whether tampered, expired, replayed against a different request or principal, or sealed under a key this server doesn't know, gets the same answer: + +```json +{"code": -32602, "message": "Invalid or expired requestState"} +``` + +One frozen message for every cause, so the wire never reveals which check failed; the real reason goes to the server log. Every inbound `requestState` on `tools/call`, `prompts/get`, and `resources/read` is checked, including one arriving for a handler that never mints state. The most common rejection in practice isn't an attacker — it's the default process-local key meeting a retry from before a restart or from another instance; the client restarts the flow, and `keys=[...]` is the fix when that matters. + +### Hand-built state + +A `request_state` you set yourself (returning `InputRequiredResult` from a tool, prompt, or resource-template function) is sealed and verified by the same machinery as resolver state, with zero code changes: write plaintext, read plaintext, and every binding above applies. + +The one thing the SDK cannot pin for you, even when configured, is question identity: it doesn't know which of *your* questions an answer in your state belongs to. If you store answers keyed by question, include your own question identifier in the state and check it on the retry. + +The low-level `Server` is the no-batteries tier: unlike `MCPServer`, nothing is sealed until you append the boundary yourself, and your `request_state` crosses the wire exactly as written until you do. The one-line opt-in is shown in **[The low-level Server](../advanced/low-level-server.md#the-other-handlers)**. + +## A 2026-07-28 result + +`InputRequiredResult` only exists at protocol version **2026-07-28**. The in-memory `Client(server)` negotiates it for you; over the wire, `mode="auto"` discovers it. After connecting, `client.protocol_version` tells you what you got. + +!!! warning + A pre-2026 session has nowhere to put an `InputRequiredResult`. Return one from your handler on a + `mode="legacy"` connection and the runner cannot serialize it into the negotiated version; the + client gets back a `-32603` *"Handler returned an invalid result"* error. A server that serves + both eras must check `ctx.protocol_version` before reaching for it. + +!!! info + **URL-mode elicitation** rides this exact mechanism on a 2026 connection. The entry in + `input_requests` is an `ElicitRequest` whose params are `ElicitRequestURLParams`; the user + finishes the out-of-band flow and your client retries the call. Same loop, no new API. The + high-level server half is in **[Elicitation](elicitation.md)**. + +## Recap + +* At 2026-07-28 a server that needs input mid-call **returns** an `InputRequiredResult`. It never opens a request to the client. +* `input_requests` is what it needs. `request_state` is an opaque resume token only the server reads. +* `Client` runs the retry loop for you: register `elicitation_callback` / `sampling_callback` / `list_roots_callback` and `call_tool` returns a plain `CallToolResult`. `input_required_max_rounds` (default 10) bounds it. +* To inspect or persist rounds, use `client.session.call_tool(..., allow_input_required=True)` and own the `while isinstance(result, InputRequiredResult)` loop yourself. +* On `@mcp.tool()`, a dependency that asks the user produces this result for you (**[Dependencies](dependencies.md)**); the **low-level** `Server` is the manual form. +* Prompts and resources participate too: an `@mcp.prompt()` or template `@mcp.resource()` function returns the `InputRequiredResult` itself and reads `ctx.input_responses` on the retry. +* `requestState` comes back as client-supplied input, so `MCPServer` seals it by default — resolver state and hand-built state alike — under a process-local key; multi-instance deployments pass `RequestStateSecurity(keys=[...])` (or a custom codec) so every instance can verify what a sibling minted. The seal binds every token to a time window, the originating request, and the authenticated principal when the request carries auth the SDK validated or `bind_principal=` supplies your own identity signal (**[Protecting `requestState`](#protecting-requeststate)**). + +This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **[Deprecated features](../deprecated.md)**. diff --git a/docs/handlers/progress.md b/docs/handlers/progress.md new file mode 100644 index 0000000..57bbb59 --- /dev/null +++ b/docs/handlers/progress.md @@ -0,0 +1,117 @@ +# Progress + +A tool that takes thirty seconds and says nothing for thirty seconds looks broken. + +**Progress notifications** fix that. The tool reports how far along it is; the client decides what to draw with it: a bar, a spinner, a log line. + +## Report it from the tool + +Take a **`Context`** parameter and call `report_progress`: + +```python title="server.py" hl_lines="8 11" +--8<-- "docs_src/progress/tutorial001.py" +``` + +Three arguments, and you decide what they mean: + +* `progress`: how far you are. The spec requires it to **increase** with every report; never repeat a value or go backwards. +* `total`: how much there is in total, if you know. Optional. +* `message`: one human-readable line about *this* step. Optional. + +`ctx` is injected because of its type hint and the model never sees it: `import_catalog`'s input schema has a single property, `urls`. **[The Context](context.md)** page is all about that object; progress is one of the things it gives you. + +## Listen for it from the client + +The client opts in **per call**, by passing `progress_callback=` to `call_tool`: + +```python title="client.py" hl_lines="7 16" +import anyio +from mcp import Client + +from server import mcp + + +async def show(progress: float, total: float | None, message: str | None) -> None: + print(f"{message} ({progress}/{total})") + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.call_tool( + "import_catalog", + {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, + progress_callback=show, + ) + print(result.structured_content) + + +anyio.run(main) +``` + +The callback is an `async` function taking exactly what the server reported: `progress`, `total`, `message`. + +!!! info + `Client(mcp)` connects straight to the server object, in memory, the same client the **[Testing](../get-started/testing.md)** + page is built on. `progress_callback` is the same parameter whatever transport the `Client` + uses; the *timing* you are about to see is the in-memory connection's. It runs your callback + inline, so every report lands before `call_tool` returns. Over a real transport the + notifications race the result, and a slow callback can still be running after `call_tool` has + returned. + +### Try it + +Put `client.py` next to `server.py` and run it: + +```console +python client.py +``` + +```text +Imported https://example.com/a.json (1/2) +Imported https://example.com/b.json (2/2) +{'result': 'Imported 2 records.'} +``` + +Every `await ctx.report_progress(...)` on the server became one call to `show` on the client, in order, and both lines printed **before** `call_tool` returned. Progress is not bundled into the result; it streams while the tool is still working. + +!!! warning + `progress_callback` belongs to the **call**, not the `Client`. There is no constructor argument + for it, because different calls want different callbacks: one drives a download bar, the next + one a log line. + +!!! check + Now delete `progress_callback=show` and run it again: + + ```text + {'result': 'Imported 2 records.'} + ``` + + No error, no warning, same result. `report_progress` is a **no-op when the caller didn't ask + for progress**, so you report unconditionally and never have to wonder whether anyone is + listening. + +## When you don't know the total + +`total` is for when you know the denominator. Often you don't: you're draining a feed, walking a cursor, downloading something with no length header. + +Leave it out: + +```python title="server.py" hl_lines="20" +--8<-- "docs_src/progress/tutorial002.py" +``` + +The callback receives `total=None`. A client can still show *activity* ("3 imported so far...") but it can't show a percentage. Don't invent a total to get a prettier bar. + +!!! tip + `progress` doesn't have to count anything in particular. Bytes, rows, pages: pick the unit the + user would recognise, and only promise a `total` you can keep. + +## Recap + +* `await ctx.report_progress(progress, total=None, message=None)` from any tool that takes a `Context`. +* The client passes `progress_callback=` to `call_tool`: per call, never on the `Client`. +* The callback is `async (progress, total, message) -> None` and fires while the tool is still running. +* No callback on the call means `report_progress` does nothing. Report unconditionally. +* Omit `total` when you don't know it; the callback gets `None`. + +Progress is what a running tool shows the *user*. The lines it logs for *you*, the person operating the server, are a different channel: **[Logging](logging.md)**. diff --git a/docs/handlers/sampling-and-roots.md b/docs/handlers/sampling-and-roots.md new file mode 100644 index 0000000..6174f42 --- /dev/null +++ b/docs/handlers/sampling-and-roots.md @@ -0,0 +1,46 @@ +# Sampling and roots + +A handler can ask the connected client for two more things: a completion from the client's own model (**sampling**), and the client's workspace folders (**roots**). + +Both still work, on every protocol version the SDK speaks. But read the warning before you design around them: + +!!! warning "Deprecated by the 2026-07-28 specification" + Sampling and roots are deprecated as of `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2577)). They remain fully functional and stay in the specification for at least twelve months before becoming eligible for removal, but new implementations should not build on them. The suggested migrations: integrate directly with your LLM provider's API instead of sampling, and pass directories via tool parameters, resource URIs, or server configuration instead of roots. The SDK-wide list is in **[Deprecated features](../deprecated.md)**. + +## Sampling: borrow the client's model + +A resolver returns `Sample(...)` and the tool receives the completion, through the same dependency mechanism that runs `Elicit` in **[Dependencies](dependencies.md)**: + +```python title="server.py" hl_lines="11-16 20" +--8<-- "docs_src/sampling_and_roots/tutorial001.py" +``` + +* `Sample(messages, max_tokens=...)` mirrors the `sampling/createMessage` parameters. The injected value is the client's `CreateMessageResult`; pass `tools` or `tool_choice` and it becomes a `CreateMessageResultWithTools` instead. +* The client must have declared the `sampling` capability (`sampling.tools` if you pass `tools` or `tool_choice`). If it didn't, the call fails with a `-32021` protocol error instead of sending a request the client cannot handle. A pre-2026 session with no back-channel fails with its usual no-back-channel error, since there is nothing to send on. +* At `2026-07-28` the request is delivered inside the multi-round-trip flow (**[Multi-round-trip requests](multi-round-trip.md)**); on `2025-11-25` it is a standalone request to the client. The code is the same either way, but mind the multi-round-trip rule: the request must render identically across retry rounds, so build it only from the tool's arguments and other stable data. +* Leave `include_context` alone: values other than `"none"` are themselves deprecated (SEP-2596) and need a capability almost no client declares. + +## Roots: where should this go? + +Roots are the folders the client says the server may operate on. They are informational guidance, not an access-control mechanism. A resolver returns `ListRoots()`: + +```python title="server.py" hl_lines="11-12 16" +--8<-- "docs_src/sampling_and_roots/tutorial002.py" +``` + +* The injected `ListRootsResult` carries a list of `Root`s: a `file://` URI and an optional display name. +* The gate is the same as for sampling: without a declared `roots` capability the call fails with `-32021` instead of sending the request. + +On the other side of the wire, the client answers both requests with the callbacks it already has: `sampling_callback` and `list_roots_callback`, covered in **[Client callbacks](../client/callbacks.md)**. + +## On 2025-era connections + +`ctx.session.create_message(...)` and `ctx.session.list_roots()` still exist for code that drives the session directly. They only work where a back-channel exists (2025-era, non-stateless connections), and calling them raises a deprecation warning. The resolver markers above are the supported form: they pick the delivery from the negotiated version and don't warn. + +## Recap + +* Return `Sample(...)` or `ListRoots()` from a resolver; the tool receives the `CreateMessageResult` or `ListRootsResult` like any other dependency. +* The client must declare the matching capability, or the call fails with `-32021` instead of a request being sent. +* Both features are deprecated at `2026-07-28`: fully functional for now, wrong for new designs. Prefer provider APIs over sampling and explicit parameters over roots. + +Reporting how far along a slow tool is: **[Progress](progress.md)**. diff --git a/docs/handlers/subscriptions.md b/docs/handlers/subscriptions.md new file mode 100644 index 0000000..85b9632 --- /dev/null +++ b/docs/handlers/subscriptions.md @@ -0,0 +1,146 @@ +# Subscriptions + +A server's catalog is not fixed. Tools appear at runtime, and the content behind a resource URI changes. + +**Subscriptions** are how a client hears about it. The client sends one `subscriptions/listen` request, and the response to that request *is* the stream: it stays open and carries the change notifications the client asked for. + +## Publish it from the tool + +Your side of it is one line: publish the change. + +```python title="server.py" hl_lines="20 32" +--8<-- "docs_src/subscriptions/tutorial001.py" +``` + +* `await ctx.notify_resource_updated("board://sprint")` reaches every open stream that subscribed to that URI. Nobody else. +* `await ctx.notify_tools_changed()` reaches every stream that asked for tool-list changes. A client that receives it calls `tools/list` again, and now sees `sprint_report`. +* The siblings are `notify_prompts_changed()` and `notify_resources_changed()`. +* No subscribers, no work. Publishing to an idle server is a no-op, so you never check whether anyone is listening. You state what changed. + +`MCPServer` serves `subscriptions/listen` for you. The wire obligations (the acknowledgment as the first frame, per-stream filtering, the subscription id on every frame) are the SDK's job. + +!!! check + On the wire, a stream whose filter named `board://sprint` looks like this after `complete_task` runs: + + ```json + {"method": "notifications/subscriptions/acknowledged", + "params": {"notifications": {"resourceSubscriptions": ["board://sprint"]}, "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} + + {"method": "notifications/resources/updated", + "params": {"uri": "board://sprint", "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} + ``` + + Note what the update does *not* carry: the board. Every frame carries the listen request's JSON-RPC id under `_meta`, and that id is the subscription id. The client mints it: the Python `Client` uses strings like `"listen-1"`; other clients may use integers. + +## Only what was asked for + +The filter is a contract. A stream that requested tool-list changes and one resource URI receives those two kinds and nothing else. Publish a prompt change and that stream stays silent. + +`MCPServer` matches resource URIs as exact strings, so a stream that named `board://sprint` hears nothing about `board://sprint/tasks/1`. The spec lets a server report a change on a sub-resource of a subscribed URI; `MCPServer` never does, but clients are built to expect it. + +Two things the stream is *not*: + +* **It is not a replay log.** A dropped stream is gone, and events published while nobody was connected are not queued. Clients re-listen and refetch. +* **It is not the 2025 path.** Clients that called `resources/subscribe` are served by `ctx.session.send_resource_updated(uri)`. The `notify_*` methods reach `subscriptions/listen` streams only. + +!!! warning + Don't publish sensitive per-user URIs through `notify_resource_updated` on a multi-tenant + server. Any client may name any URI in its filter, and `MCPServer` honors it. The exposure + is narrow but real: a subscriber learns that a URI it can guess changed, and when. It never + learns content, and it cannot probe what exists, because an unknown URI is honored too and + simply never fires. To narrow the filter per client today, serve the method with your own + handler on the low-level `Server` and acknowledge a smaller filter than the client asked + for; the acknowledgment is how the client learns what it actually got. + +!!! warning "Streamable HTTP only, for now" + `subscriptions/listen` needs a transport that can stream a request's response, which today + means streamable HTTP. Over stdio a 2026-07-28 connection rejects the method with + METHOD_NOT_FOUND, even though `server/discover` advertises the subscription capabilities + there. Serving it over stdio is planned; the open-stream semantics for that transport are + not built yet. + +## The client end + +Here is a client on the other side of that stream, following the board: + +```python title="client.py" hl_lines="16" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +Entering `client.listen(...)` sends the request and waits for your acknowledgment, so the stream is live when the block starts, and each typed event is a cue to refetch, never a payload. That is the whole contract in one screen. Everything else about the client end lives on its own page: watching beside a main flow, stream endings, and re-listening. See **[Subscriptions](../client/subscriptions.md)** under *Clients*. + +## Scaling past one process + +Publishes travel from your handler to the open streams over a `SubscriptionBus`. The default is in-memory: one process, every stream in it. That is the right answer until you run replicas behind a load balancer, because then a client's stream is pinned to one replica, and a publish on another replica has to reach it. + +That seam is yours to implement: two methods over your pub/sub backend. + +```python +from collections.abc import Callable + +from redis.asyncio import Redis + +from mcp.server.mcpserver import MCPServer +from mcp.server.subscriptions import ServerEvent # SubscriptionBus is a Protocol: no base class + + +class RedisSubscriptionBus: + def __init__(self, redis: Redis) -> None: + self._redis = redis + self._listeners: dict[object, Callable[[ServerEvent], None]] = {} + + async def publish(self, event: ServerEvent) -> None: + await self._redis.publish("mcp-events", encode(event)) # to every replica + + def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]: + token = object() + self._listeners[token] = listener + + def unsubscribe() -> None: + self._listeners.pop(token, None) + + return unsubscribe + + +mcp = MCPServer("Sprint Board", subscriptions=RedisSubscriptionBus(redis)) +``` + +`encode` is yours, and so is the reader task on each replica that decodes arriving messages and calls every registered listener. Listeners are synchronous, must not raise, and run on the server's event loop. + +The bus carries typed `ServerEvent` values, four small dataclasses, never JSON-RPC. Stamping, filtering, and stream lifecycles stay in the SDK, so a bus implementation cannot break the protocol. It can only move events between processes. + +To publish from outside a request, construct the bus yourself so you hold the reference. `MCPServer` builds one internally when you pass nothing, and does not expose it. + +```python +from mcp.server.subscriptions import InMemorySubscriptionBus, ToolsListChanged + +bus = InMemorySubscriptionBus() +mcp = MCPServer("Sprint Board", subscriptions=bus) + + +async def tools_reloaded() -> None: + await bus.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere +``` + +## The low-level composition + +Down on the low-level `Server` there is no pre-wired anything, and the same parts assemble in three lines: + +```python title="server.py" hl_lines="9-10 48" +--8<-- "docs_src/subscriptions/tutorial002.py" +``` + +* You own the bus, so you publish to it directly: `await bus.publish(ResourceUpdated(uri=...))`. Put it wherever your handlers can reach it: module scope here, the lifespan in a bigger app. +* `ListenHandler(bus)` is the same handler `MCPServer` registers, and `on_subscriptions_listen=` is an ordinary handler slot. Put your own callable in that slot for different semantics, and the spec obligations move to you: acknowledge first, stamp every frame with the subscription id, deliver nothing outside the filter. +* `ListenHandler.close()` ends every open stream gracefully. Each one receives the listen request's result as its final frame, which is the spec's way of saying the server ended the subscription deliberately. It returns before those streams finish flushing, so give them a moment before you tear the transport down. Without it, streams end when the client disconnects. + +## Recap + +* A client opts in with one `subscriptions/listen` request, and the response is the stream. Serving it is built in. +* You publish with `ctx.notify_*`, and the SDK does the stamping, filtering, and lifecycle work. +* Events are cues, not payloads. Both ends refetch. +* The client end is `async with client.listen(...)`: **[Subscriptions](../client/subscriptions.md)** under *Clients* is that story. +* On the low-level `Server` you assemble the same parts yourself: a bus, `ListenHandler(bus)`, the `on_subscriptions_listen` slot. +* Scaling out means implementing `SubscriptionBus`, two methods, and passing it as `MCPServer(subscriptions=...)`. + +Running the server that serves all this, behind one replica or twenty, is **[Deploy & scale](../run/deploy.md)**. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..8aa1a5b --- /dev/null +++ b/docs/index.md @@ -0,0 +1,102 @@ +# MCP Python SDK + +!!! info "You are viewing the in-development v2 documentation" + For the current stable release, see the [v1.x documentation](https://py.sdk.modelcontextprotocol.io/). + New to v2, or coming from v1? **[What's new in v2](whats-new.md)** is the five-minute tour of what changed. + Trying v2? [Tell us what you find](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml) — it is the most useful thing you can do for the SDK right now. + +The **Model Context Protocol (MCP)** lets applications provide context to LLMs in a standardized way, separating the concern of *providing* context from the LLM interaction itself. + +This is the official Python SDK for it. With it you can: + +* **Build MCP servers** that expose tools, resources, and prompts to any MCP host. +* **Build MCP clients** that connect to any MCP server. +* Speak every standard transport: stdio, Streamable HTTP, and SSE. + +## Requirements + +Python 3.10+. + +## Installation + +=== "uv" + + ```bash + uv add "mcp[cli]==2.0.0b1" + ``` + +=== "pip" + + ```bash + pip install "mcp[cli]==2.0.0b1" + ``` + +The `[cli]` extra gives you the `mcp` command; you'll want it for development. + +!!! warning "Pin the version while v2 is in beta" + Installers never select a pre-release unless you name one, so an unpinned `uv add "mcp[cli]"` + gives you the latest **v1.x** release, which this documentation does not describe. Check + [PyPI](https://pypi.org/project/mcp/#history) for the newest beta before you copy the line + above. See [Installation](get-started/installation.md) for the details. + +## Example + +### Create it + +Create a file `server.py`: + +```python title="server.py" +--8<-- "docs_src/index/tutorial001.py" +``` + +That's a complete MCP server. + +It exposes one **tool**, `add`, and one templated **resource**, `greeting://{name}`. + +### Run it + +```console +uv run mcp dev server.py +``` + +This starts your server and opens the [MCP Inspector](https://github.com/modelcontextprotocol/inspector), an interactive UI for poking at it. Open the URL it prints. + +!!! note + The Inspector is a Node.js app, so `mcp dev` needs `npx` on your `PATH`. + +### Try it + +In the Inspector, go to **Tools** and call `add` with `a=1`, `b=2`. + +You get `3` back. ✨ + +The Inspector built that form (a required integer field for `a`, another for `b`) from your type hints. So will Claude, and every other MCP host. + +Now go to **Resources** and read `greeting://World`: + +```text +Hello, World! +``` + +### Recap + +Look again at what you did **not** write: + +* No JSON Schema. `a: int, b: int` *is* the schema. +* No request parsing, no serialization, no validation code. +* No protocol handling at all. + +You wrote two Python functions with type hints and a docstring. The SDK does the rest. + +## Where to go next + +* **[Get started](get-started/index.md)** takes you from install to a working, tested server. +* Building an application that *uses* MCP servers? Start with **[Clients](client/index.md)**. +* Already have a FastAPI or Starlette app? **[Add to an existing app](run/asgi.md)** mounts an MCP server inside it. +* Hunting an exact error message? **[Troubleshooting](troubleshooting.md)** is keyed by the verbatim text. +* Wondering what changed in v2? **[What's new in v2](whats-new.md)** is the five-minute tour. +* Migrating from v1? Start with the **[Migration Guide](migration.md)**. +* Hunting for an exact signature? The **[API Reference](api/mcp/index.md)** is generated from the source. +* Reading with an LLM? This documentation is also published in the [llms.txt](https://llmstxt.org/) format: + [llms.txt](https://py.sdk.modelcontextprotocol.io/v2/llms.txt) is an index of the pages, and + [llms-full.txt](https://py.sdk.modelcontextprotocol.io/v2/llms-full.txt) contains every page in a single file. diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..8822d44 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,2125 @@ +# Migration Guide: v1 to v2 + +This guide covers the breaking changes introduced in v2 of the MCP Python SDK and how to update your code. + +Version 2 of the MCP Python SDK introduces several breaking changes to improve the API, align with the MCP specification, and provide better type safety. + +## Find your changes + +Every section heading below names the API it affects, so searching this page for the symbol your code uses is the fastest route to the change that broke it. + +### Changes almost every project hits + +| Change | First symptom | Section | +|---|---|---| +| `FastMCP` renamed to `MCPServer` | `ModuleNotFoundError: No module named 'mcp.server.fastmcp'` | [`FastMCP` renamed](#fastmcp-renamed-to-mcpserver) | +| Fields renamed from camelCase to snake_case | `AttributeError: 'Tool' object has no attribute 'inputSchema'` | [snake_case fields](#field-names-changed-from-camelcase-to-snake_case) | +| `mcp.types` moved to the `mcp-types` package | `ModuleNotFoundError: No module named 'mcp.types'` | [`mcp.types` moved](#mcptypes-moved-to-the-mcp-types-package) | +| `McpError` renamed to `MCPError` | `ImportError: cannot import name 'McpError' from 'mcp'` | [`McpError` renamed](#mcperror-renamed-to-mcperror) | +| Resource URIs are `str`, not `AnyUrl` | `AttributeError: 'str' object has no attribute 'host'` | [URI type](#resource-uri-type-changed-from-anyurl-to-str) | +| `streamablehttp_client` removed | `ImportError: cannot import name 'streamablehttp_client'` | [`streamablehttp_client`](#streamablehttp_client-removed) | +| `Client` defaults to `mode='auto'` | servers log an unexpected `server/discover` request | [`mode='auto'`](#client-defaults-to-modeauto) | +| Transport parameters moved off the `MCPServer` constructor | `TypeError: MCPServer.__init__() got an unexpected keyword argument 'port'` | [constructor parameters](#transport-specific-parameters-moved-from-mcpserver-constructor-to-runapp-methods) | +| Sync handlers run on a worker thread | `asyncio.get_running_loop()` in a `def` handler raises `RuntimeError` | [worker threads](#sync-handler-functions-now-run-on-a-worker-thread) | +| Lowlevel decorators replaced with `on_*` constructor params | `AttributeError: 'Server' object has no attribute 'list_tools'` | [`on_*` handlers](#lowlevel-server-decorator-based-handlers-replaced-with-constructor-on_-params) | +| Lowlevel return value wrapping removed | bare list or dict returns fail result validation instead of being wrapped | [wrapping removed](#lowlevel-server-automatic-return-value-wrapping-removed) | +| Lowlevel tool exceptions no longer become `isError: true` results | clients raise a JSON-RPC error instead of seeing the error text | [tool exceptions](#lowlevel-server-tool-handler-exceptions-no-longer-become-calltoolresultis_errortrue) | +| Roots, Sampling, and Logging deprecated (SEP-2577) | `MCPDeprecationWarning` at call sites | [SEP-2577](#roots-sampling-and-logging-methods-deprecated-sep-2577) | + +### Find your area + +| If you... | Read | +|---|---| +| pin dependencies or use the `mcp` CLI | [Packaging, dependencies, and CLI](#packaging-dependencies-and-cli) | +| import `mcp.types` or touch protocol types (everyone does) | [Types and wire format](#types-and-wire-format) | +| run `FastMCP`/`MCPServer` servers | [MCPServer (formerly FastMCP)](#mcpserver-formerly-fastmcp) | +| use the lowlevel `Server` | [Lowlevel Server](#lowlevel-server), plus [Timeouts take `float` seconds](#timeouts-take-float-seconds-instead-of-timedelta) and [Experimental Tasks support removed](#experimental-tasks-support-removed) under Clients | +| write client code with `Client` or `ClientSession` | [Clients](#clients), plus [`streamablehttp_client` removed](#streamablehttp_client-removed) under Transports | +| use stdio or streamable HTTP directly, or maintain a custom transport | [Transports](#transports) | +| maintain OAuth client auth or a protected server | [OAuth and server auth](#oauth-and-server-auth) | +| relied on lenient handling of off-schema traffic, or assert on exact wire bytes | [Stricter protocol validation and wire behavior](#stricter-protocol-validation-and-wire-behavior) | +| test against in-memory server/client pairs | [Testing utilities](#testing-utilities) | +| use roots, sampling, logging, or client-to-server progress | [Deprecations](#deprecations) | +| operate servers that 2026-era clients will also connect to | [Notes for 2026-era connections](#notes-for-2026-era-connections) | + +## Suggested migration order + +1. Update your dependency pins and CLI usage: [Packaging, dependencies, and CLI](#packaging-dependencies-and-cli). +2. Apply the mechanical renames and import moves: [Types and wire format](#types-and-wire-format). +3. Port your server surface: [MCPServer (formerly FastMCP)](#mcpserver-formerly-fastmcp) or [Lowlevel Server](#lowlevel-server). +4. Port your client code: [Clients](#clients). +5. Update transport setup and auth: [Transports](#transports) and [OAuth and server auth](#oauth-and-server-auth). +6. Run your tests and check anything that now errors against [Stricter protocol validation and wire behavior](#stricter-protocol-validation-and-wire-behavior) and [Testing utilities](#testing-utilities). +7. Address deprecation warnings: [Deprecations](#deprecations). + +## Packaging, dependencies, and CLI + +### Dependency floors raised and new required dependencies + +v2 raises the minimum versions of several shared dependencies and adds new required ones. A project that pins any of these below the new floor fails dependency resolution before anything installs (uv reports "No solution found when resolving dependencies"; pip fails similarly). + +| Dependency | v1.28.1 | v2 | Change | +|---|---|---|---| +| anyio | `>=4.5` | `>=4.9` (Python <3.14) / `>=4.10` (Python >=3.14) | floor raised | +| pydantic | `>=2.11,<3` (Python <3.14) | `>=2.12` | floor raised on Python <3.14; `<3` cap dropped | +| sse-starlette | `>=1.6.1` | `>=3.0.0` | floor raised across two majors | +| typing-extensions | `>=4.9.0` | `>=4.13.0` | floor raised | +| pywin32 (Windows) | `>=310` (Python <3.14) | `>=311` | floor raised on Python <3.14 | +| opentelemetry-api | not a dependency | `>=1.28.0` | new required dependency | +| mcp-types | not a dependency | `==<exact mcp version>` | new, exact-pinned | +| `ws` extra | `websockets>=15.0.1` | removed | see [WebSocket transport removed](#websocket-transport-removed) | + +**Before (v1):** + +```toml +dependencies = [ + "mcp==1.28.1", + "sse-starlette>=2,<3", # own SSE endpoints, pinned to the 2.x API +] +``` + +**After (v2):** + +```toml +dependencies = [ + "mcp>=2,<3", + "sse-starlette>=3", # absorb sse-starlette's own 2.x -> 3.x changes +] +``` + +Relax or bump any conflicting pins when upgrading. sse-starlette jumps two majors, so a project that imports `sse_starlette` itself must also work through that library's own breaking changes to co-install with mcp v2. `opentelemetry-api` is a new hard dependency because every outbound request now carries a `_meta` envelope used for OpenTelemetry trace propagation; see [Every outbound request now carries a `_meta` envelope](#every-outbound-request-now-carries-a-_meta-envelope-opentelemetry-is-on-by-default). `mcp-types` is exact-pinned to the SDK version; nothing in a v1 tree can conflict with it, but do not pin `mcp-types` independently of `mcp`. + +### `mcp dev` and `mcp install` pin the spawned environment to your SDK version + +Both commands run your server through a fresh `uv run --with ...` environment. In v1 the +`mcp` requirement in that command was unpinned, so the spawned environment resolved to the +newest stable release rather than the version you had installed; with a v2 pre-release +installed, `mcp dev server.py` built a v1 environment that could not import a v2 server. +Both commands now pin the requirement to the version you are running +(`mcp==<installed version>`). Source builds and other unpublished versions, which have +nothing on PyPI to pin to, keep the unpinned form. + +## Types and wire format + +### `mcp.types` moved to the `mcp-types` package + +The protocol wire types now live in a standalone distribution, `mcp-types`, imported as +`mcp_types`. Its only runtime dependencies are `pydantic` and `typing-extensions`, so code +that just needs to (de)serialize MCP traffic can install it without the full SDK. The `mcp` package depends on `mcp-types` and +continues to re-export the type names at the top level, so `from mcp import Tool` is +unchanged. Only the `mcp.types` submodule and `mcp.shared.version` were removed. The +package's API reference is at [`mcp_types`](api/mcp_types/index.md). + +**Why:** keeping the wire types in their own package lets tooling and lightweight clients +depend on the protocol schema without pulling in `httpx`, `starlette`, `uvicorn`, and the +rest of the server/transport stack. + +**Before (v1):** + +```python +from mcp.types import Tool, Resource +from mcp.shared.version import LATEST_PROTOCOL_VERSION +``` + +**After (v2):** + +```python +from mcp_types import Tool, Resource +from mcp_types.version import LATEST_PROTOCOL_VERSION + +# Names `mcp` already re-exported at the top level are unchanged: +from mcp import Tool, Resource +``` + +### Removed type aliases and classes + +The following type aliases and classes have been removed from `mcp_types`: + +| Removed | Replacement | +|---------|-------------| +| `Content` | `ContentBlock` | +| `ResourceReference` | `ResourceTemplateReference` | +| `Cursor` | Use `str` directly | +| `MethodT` | Internal TypeVar, not intended for public use | +| `RequestParamsT` | Internal TypeVar, not intended for public use | +| `NotificationParamsT` | Internal TypeVar, not intended for public use | +| `AnyFunction` | Use `Callable[..., Any]` directly | +| `ClientRequestType`, `ClientNotificationType`, `ClientResultType`, `ServerRequestType`, `ServerNotificationType`, `ServerResultType` | The union is now the bare name: `ClientRequest`, `ClientNotification`, `ClientResult`, `ServerRequest`, `ServerNotification`, `ServerResult` | +| `TaskExecutionMode`, `TASK_FORBIDDEN`, `TASK_OPTIONAL`, `TASK_REQUIRED`, `TASK_STATUS_*` | Use string literals; `TaskStatus` remains as the literal-union type | + +**Before (v1):** + +```python +from mcp.types import Content, ResourceReference, Cursor +``` + +**After (v2):** + +```python +from mcp_types import ContentBlock, ResourceTemplateReference +# Use `str` instead of `Cursor` for pagination cursors +``` + +### Field names changed from camelCase to snake_case + +All Pydantic model fields in `mcp_types` now use snake_case names for Python attribute access. The JSON wire format is unchanged — traffic the SDK sends still uses camelCase via Pydantic aliases, but your own `model_dump()` calls now need `by_alias=True` to produce it. + +**Before (v1):** + +```python +result = await session.call_tool("my_tool", {"x": 1}) +if result.isError: + ... + +tools = await session.list_tools() +cursor = tools.nextCursor +schema = tools.tools[0].inputSchema +``` + +**After (v2):** + +```python +result = await session.call_tool("my_tool", {"x": 1}) +if result.is_error: + ... + +tools = await session.list_tools() +cursor = tools.next_cursor +schema = tools.tools[0].input_schema +``` + +Common renames: + +| v1 (camelCase) | v2 (snake_case) | +|----------------|-----------------| +| `inputSchema` | `input_schema` | +| `outputSchema` | `output_schema` | +| `isError` | `is_error` | +| `nextCursor` | `next_cursor` | +| `mimeType` | `mime_type` | +| `structuredContent` | `structured_content` | +| `serverInfo` | `server_info` | +| `protocolVersion` | `protocol_version` | +| `uriTemplate` | `uri_template` | +| `listChanged` | `list_changed` | +| `progressToken` | `progress_token` | + +The models accept both spellings at construction time, so the old camelCase names still work as constructor kwargs (e.g., `Tool(inputSchema={...})` is accepted), but attribute access must use snake_case (`tool.input_schema`). + +**If you serialize models yourself, pass `by_alias=True`.** In v1, `model_dump()` produced wire-format camelCase keys because the fields themselves were camelCase. In v2 the same call emits snake_case keys (`input_schema`, not `inputSchema`), which peers and other MCP implementations will not recognize. No error is raised; the output is silently in the wrong shape. + +```python +tool.model_dump() # {"name": ..., "input_schema": ...} +tool.model_dump(by_alias=True, mode="json") # {"name": ..., "inputSchema": ...} (wire format) +``` + +Parsing is unaffected: `model_validate()` accepts both camelCase wire JSON and snake_case dumps. + +### Extra fields on MCP types are no longer preserved + +In v1, MCP protocol types were configured with `extra="allow"`: unknown fields passed to a constructor or received from a peer were kept on the model and re-serialized on output. + +In v2, MCP types silently ignore extra fields. Unknown constructor keyword arguments and unknown keys in wire data are dropped during validation — no error is raised, and the values do not round-trip: + +```python +from mcp_types import CallToolRequestParams + +params = CallToolRequestParams( + name="my_tool", + arguments={}, + unknown_field="value", # silently ignored, not stored +) +"unknown_field" in params.model_dump() # False + +# _meta remains the supported place for custom data, per the MCP spec +params = CallToolRequestParams( + name="my_tool", + arguments={}, + _meta={"my_custom_key": "value", "another": 123}, # OK, preserved +) +``` + +If you relied on extra fields round-tripping through MCP types, move that data into `_meta`. + +### Resource URI type changed from `AnyUrl` to `str` + +The `uri` field on resource-related types now uses `str` instead of Pydantic's `AnyUrl`. This aligns with the [MCP specification schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-11-25/schema.ts) which defines URIs as plain strings (`uri: string`) without strict URL validation. This change allows relative paths like `users/me` that were previously rejected. + +**Before (v1):** + +```python +from pydantic import AnyUrl +from mcp.types import Resource + +# uri was typed as AnyUrl; relative paths were rejected +resource = Resource(name="test", uri=AnyUrl("users/me")) # Would fail validation +``` + +**After (v2):** + +```python +from mcp_types import Resource + +# Plain strings accepted +resource = Resource(name="test", uri="users/me") # Works +resource = Resource(name="test", uri="custom://scheme") # Works +resource = Resource(name="test", uri="https://example.com") # Works +``` + +If your code passes `AnyUrl` objects to URI fields, convert them to strings: + +```python +# If you have an AnyUrl from elsewhere +uri = str(my_any_url) # Convert to string +``` + +Affected types: + +- `Resource.uri` (and subclass `ResourceLink`) +- `ReadResourceRequestParams.uri` +- `ResourceContents.uri` (and subclasses `TextResourceContents`, `BlobResourceContents`) +- `SubscribeRequestParams.uri` +- `UnsubscribeRequestParams.uri` +- `ResourceUpdatedNotificationParams.uri` + +The `Client` and `ClientSession` methods `read_resource()`, `subscribe_resource()`, and `unsubscribe_resource()` now only accept `str` for the `uri` parameter. If you were passing `AnyUrl` objects, convert them to strings: + +```python +# Before (v1) +from pydantic import AnyUrl + +await client.read_resource(AnyUrl("test://resource")) + +# After (v2) +await client.read_resource("test://resource") +# Or if you have an AnyUrl from elsewhere: +await client.read_resource(str(my_any_url)) +``` + +URI values you read back are also plain strings now. In v1, fields like `Resource.uri` and `ResourceContents.uri` were `AnyUrl` objects, so attribute access such as `uri.scheme` or `uri.host` worked; in v2 that code raises `AttributeError`. Use `urllib.parse` if you need to parse them. Note that v1 also normalized URIs during validation (for example `https://example.com` became `https://example.com/`), while v2 preserves the string exactly as given, so URIs sent on the wire may differ byte-for-byte from what v1 sent. + +### Replace `RootModel` by union types with `TypeAdapter` validation + +The following union types are no longer `RootModel` subclasses: + +- `ClientRequest` +- `ServerRequest` +- `ClientNotification` +- `ServerNotification` +- `ClientResult` +- `ServerResult` +- `JSONRPCMessage` + +This means you can no longer access `.root` on these types or use `model_validate()` directly on them. Instead, use the provided `TypeAdapter` instances for validation. + +**Before (v1):** + +```python +from mcp.types import ClientRequest, ServerNotification + +# Using RootModel.model_validate() +request = ClientRequest.model_validate(data) +actual_request = request.root # Accessing the wrapped value + +notification = ServerNotification.model_validate(data) +actual_notification = notification.root +``` + +**After (v2):** + +```python +from mcp_types import client_request_adapter, server_notification_adapter + +# Using TypeAdapter.validate_python() +request = client_request_adapter.validate_python(data) +# No .root access needed - request is the actual type + +notification = server_notification_adapter.validate_python(data) +# No .root access needed - notification is the actual type +``` + +The same applies when constructing values — the wrapper call is no longer needed: + +**Before (v1):** + +```python +await session.send_notification(ClientNotification(InitializedNotification())) +await session.send_request(ClientRequest(PingRequest()), EmptyResult) +``` + +**After (v2):** + +```python +await session.send_notification(InitializedNotification()) +await session.send_request(PingRequest(), EmptyResult) +``` + +**Available adapters:** + +| Union Type | Adapter | +|------------|---------| +| `ClientRequest` | `client_request_adapter` | +| `ServerRequest` | `server_request_adapter` | +| `ClientNotification` | `client_notification_adapter` | +| `ServerNotification` | `server_notification_adapter` | +| `ClientResult` | `client_result_adapter` | +| `ServerResult` | `server_result_adapter` | +| `JSONRPCMessage` | `jsonrpc_message_adapter` | + +All adapters are exported from `mcp_types`. + +### `RequestParams.Meta` replaced with `RequestParamsMeta` TypedDict + +The nested `RequestParams.Meta` Pydantic model class has been replaced with a top-level `RequestParamsMeta` TypedDict. This affects the `ctx.meta` field in request handlers and any code that imports or references this type. + +**Key changes:** + +- `RequestParams.Meta` (Pydantic model) → `RequestParamsMeta` (TypedDict) +- Attribute access (`meta.progressToken`) → Dictionary access (`meta.get("progress_token")`) +- The `progressToken: ProgressToken | None = None` field is now the `progress_token: NotRequired[ProgressToken]` key + +**In request context handlers:** + +```python +# Before (v1) +@server.call_tool() +async def handle_tool(name: str, arguments: dict) -> list[TextContent]: + ctx = server.request_context + if ctx.meta and ctx.meta.progressToken: + await ctx.session.send_progress_notification(ctx.meta.progressToken, 0.5, 100) + +# After (v2) +async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + if ctx.meta and "progress_token" in ctx.meta: + await ctx.session.send_progress_notification(ctx.meta["progress_token"], 0.5, 100) + ... + +server = Server("my-server", on_call_tool=handle_call_tool) +``` + +The nested `NotificationParams.Meta` class is gone as well. Notification `_meta` is +now a plain `dict[str, Any]`: pass a dict when constructing params +(`ProgressNotificationParams(progress_token=..., progress=0.5, _meta={"traceparent": ...})`) +and read extras with dictionary access (`params.meta["traceparent"]`) instead of +attribute access. The JSON wire format is unchanged. + +### `SUPPORTED_PROTOCOL_VERSIONS` deprecated; `LATEST_PROTOCOL_VERSION` changed meaning + +`SUPPORTED_PROTOCOL_VERSIONS` is deprecated — it's now the union of `HANDSHAKE_PROTOCOL_VERSIONS` (initialize-handshake versions) and `MODERN_PROTOCOL_VERSIONS` (per-request-envelope versions). If you were using it to mean "versions the initialize handshake accepts", switch to `HANDSHAKE_PROTOCOL_VERSIONS`. Named scalars derived from these tuples are now exported alongside them — `LATEST_HANDSHAKE_VERSION`, `LATEST_MODERN_VERSION`, `OLDEST_SUPPORTED_VERSION` — so prefer those over indexing the tuples directly. All of these live in `mcp_types.version` (previously `mcp.shared.version`): `from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS`. + +`LATEST_PROTOCOL_VERSION` also changed value and meaning. In v1 it was `"2025-11-25"`, the version the client offered during initialization. In v2 it is the newest revision the SDK speaks in any era, currently `"2026-07-28"`, which the initialize handshake cannot negotiate. If you offered it in a hand-built `initialize` request or compared the negotiated version against it, use `LATEST_HANDSHAKE_VERSION` instead. These tuples really are tuples now (`SUPPORTED_PROTOCOL_VERSIONS` was a `list` in v1), so list-only operations such as concatenating with a list raise `TypeError`. + +### `McpError` renamed to `MCPError` + +The `McpError` exception class has been renamed to `MCPError` for consistent naming with the MCP acronym style used throughout the SDK. + +**Before (v1):** + +```python +from mcp.shared.exceptions import McpError + +try: + result = await session.call_tool("my_tool") +except McpError as e: + print(f"Error: {e.error.message}") +``` + +**After (v2):** + +```python +from mcp.shared.exceptions import MCPError + +try: + result = await session.call_tool("my_tool") +except MCPError as e: + print(f"Error: {e.message}") +``` + +`MCPError` is also exported from the top-level `mcp` package: + +```python +from mcp import MCPError +``` + +The constructor signature also changed — it now takes `code`, `message`, and optional `data` directly instead of wrapping an `ErrorData`: + +**Before (v1):** + +```python +from mcp.shared.exceptions import McpError +from mcp.types import ErrorData, INVALID_REQUEST + +raise McpError(ErrorData(code=INVALID_REQUEST, message="bad input")) +``` + +**After (v2):** + +```python +from mcp.shared.exceptions import MCPError +from mcp_types import INVALID_REQUEST + +raise MCPError(INVALID_REQUEST, "bad input") +# or, if you already have an ErrorData: +raise MCPError.from_error_data(error_data) +``` + +## MCPServer (formerly FastMCP) + +### `FastMCP` renamed to `MCPServer` + +The `FastMCP` class has been renamed to `MCPServer` to better reflect its role as the main server class in the SDK. This is a simple rename with no functional changes to the class itself. + +**Before (v1):** + +```python +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("Demo") +``` + +**After (v2):** + +```python +from mcp.server.mcpserver import MCPServer, Context + +mcp = MCPServer("Demo") +``` + +`Context` is the type annotation for the `ctx` parameter injected into tools, resources, and prompts (see [`get_context()` removed](#mcpserverget_context-removed) below). The `ctx.fastmcp` property is now `ctx.mcp_server`. + +All submodules under `mcp.server.fastmcp.*` are now under `mcp.server.mcpserver.*` with the same structure. Common imports: + +- `Image`, `Audio` — from `mcp.server.mcpserver` (or `.utilities.types`) +- `UserMessage`, `AssistantMessage` — from `mcp.server.mcpserver.prompts.base` +- `ToolError`, `ResourceError` — from `mcp.server.mcpserver.exceptions` +- `MCPServerError` (renamed from `FastMCPError`) — from `mcp.server.mcpserver.exceptions` + +### Default server name changed from `FastMCP` to `mcp-server` + +A server constructed without a name now defaults to `mcp-server` instead of `FastMCP`. This is the name reported to clients as `serverInfo.name` in the initialize result, so it is visible in client UIs, logs, and monitoring. Nothing raises when this changes; the migrated server simply reports a different identity. + +**Before (v1):** + +```python +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP() # serverInfo.name == "FastMCP" +``` + +**After (v2):** + +```python +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer() # serverInfo.name == "mcp-server" +``` + +If test suites assert on the initialize result, or anything keys configuration or allow-lists off `serverInfo.name`, pass a name explicitly: `MCPServer("FastMCP")` preserves the old value, though a real name for your server is better. + +### `MCPServer` constructor: `title`, `description`, and `version` added to the positional parameters + +The constructor's positional parameter order changed. v2 inserts `title` and `description` before `instructions`, and `version` after `icons`, so the order is now `name`, `title`, `description`, `instructions`, `website_url`, `icons`, `version`. In v1 the order was `name`, `instructions`, `website_url`, `icons`. + +A v1 call that passed `instructions` positionally still runs without error on v2, because both slots are `str | None`. The text silently lands in `title` instead: the server sends it as `serverInfo.title` and stops sending `instructions` in the initialize result, which clients feed to the model. + +**Before (v1):** + +```python +from mcp.server.fastmcp import FastMCP + +# Second positional parameter is instructions +mcp = FastMCP("Demo", "You answer questions about the weather.") +``` + +**After (v2):** + +```python +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("Demo", instructions="You answer questions about the weather.") +``` + +Keep `name` positional and pass everything else by keyword. + +### `mount_path` parameter removed from MCPServer + +The `mount_path` parameter has been removed from `MCPServer.__init__()`, `MCPServer.run()`, `MCPServer.run_sse_async()`, and `MCPServer.sse_app()`. It was also removed from the `Settings` class. + +This parameter was redundant because the SSE transport already handles sub-path mounting via ASGI's standard `root_path` mechanism. When using Starlette's `Mount("/path", app=mcp.sse_app())`, Starlette automatically sets `root_path` in the ASGI scope, and the `SseServerTransport` uses this to construct the correct message endpoint path. + +### Transport-specific parameters moved from MCPServer constructor to run()/app methods + +Transport-specific parameters have been moved from the `MCPServer` constructor to the `run()`, `sse_app()`, and `streamable_http_app()` methods. This provides better separation of concerns - the constructor now only handles server identity and authentication, while transport configuration is passed when starting the server. + +**Parameters moved:** + +- `host`, `port` - HTTP server binding +- `sse_path`, `message_path` - SSE transport paths +- `streamable_http_path` - StreamableHTTP endpoint path +- `json_response`, `stateless_http` - StreamableHTTP behavior +- `event_store`, `retry_interval` - StreamableHTTP event handling +- `transport_security` - DNS rebinding protection + +**Before (v1):** + +```python +from mcp.server.fastmcp import FastMCP + +# Transport params in constructor +mcp = FastMCP("Demo", json_response=True, stateless_http=True) +mcp.run(transport="streamable-http") + +# Or for SSE +mcp = FastMCP("Server", host="0.0.0.0", port=9000, sse_path="/events") +mcp.run(transport="sse") +``` + +**After (v2):** + +```python +from mcp.server.mcpserver import MCPServer + +# Transport params passed to run() +mcp = MCPServer("Demo") +mcp.run(transport="streamable-http", json_response=True, stateless_http=True) + +# Or for SSE +mcp = MCPServer("Server") +mcp.run(transport="sse", host="0.0.0.0", port=9000, sse_path="/events") +``` + +**For mounted apps:** + +When mounting in a Starlette app, pass transport params to the app methods: + +```python +# Before (v1) +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("App", json_response=True) +app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())]) + +# After (v2) +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("App") +app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app(json_response=True))]) +``` + +**Note:** DNS rebinding protection is automatically enabled when `host` is `127.0.0.1`, `localhost`, or `::1`. This now happens in `sse_app()` and `streamable_http_app()` instead of the constructor. + +If you were mutating these via `mcp.settings` after construction (e.g., `mcp.settings.port = 9000`), pass them to `run()` / `sse_app()` / `streamable_http_app()` instead — these fields no longer exist on `Settings`. The `debug` and `log_level` parameters remain on the constructor. + +### Streamable HTTP: lifespan now entered once at manager startup + +When serving streamable HTTP (stateful or `stateless_http=True`), the server's `lifespan` context manager is now entered once when `StreamableHTTPSessionManager.run()` starts, and the resulting state is shared across all sessions and requests. Previously each session (stateful) or each request (stateless) entered and exited `lifespan` independently. + +Lifespans that set up process-wide state (connection pools, caches, background tasks) are unaffected — they now run once instead of per session/request. If your lifespan was acquiring per-connection resources, move that acquisition into the handler body; per-connection cleanup belongs on the connection's `exit_stack` (a public way to reach it from high-level `@mcp.tool()` handlers is planned). + +### `MCPServer.get_context()` removed + +`MCPServer.get_context()` has been removed. Context is now injected by the framework and passed explicitly — there is no ambient ContextVar to read from. + +**If you were calling `get_context()` from inside a tool/resource/prompt:** use the `ctx: Context` parameter injection instead. + +**Before (v1):** + +```python +@mcp.tool() +async def my_tool(x: int) -> str: + ctx = mcp.get_context() + await ctx.info("Processing...") + return str(x) +``` + +**After (v2):** + +```python +from mcp.server.mcpserver import Context + +@mcp.tool() +async def my_tool(x: int, ctx: Context) -> str: + await ctx.info("Processing...") + return str(x) +``` + +### Sync handler functions now run on a worker thread + +In v1, a synchronous (`def`) tool, resource, or prompt function was called inline on the event +loop, so a body that blocked (an HTTP call with a sync client, `time.sleep()`, heavy +computation) stalled every other in-flight request on the server. In v2 the SDK runs +synchronous handler functions in a worker thread via `anyio.to_thread.run_sync()`; +`async def` handlers are unchanged. Resolver functions (`Resolve(...)`) follow the same rule. + +Most servers simply gain concurrency. Port with care if a synchronous handler relied on +running on the event-loop thread: + +- Thread-affine state (thread locals shared with startup code, non-thread-safe objects that + were only ever touched from the event loop's thread) is now touched from a worker thread. +- `asyncio.get_running_loop()` inside a synchronous handler body raises `RuntimeError`; there + is no running loop in a worker thread. +- Synchronous handlers can run concurrently with each other, up to anyio's default + worker-thread limit. + +Declare the handler `async def` to keep it on the event loop. + +### `MCPServer.call_tool()` returns `CallToolResult` + +`MCPServer.call_tool()` now returns a `CallToolResult` (or an +`InputRequiredResult` when a multi-round tool requests further input). It previously +advertised `Sequence[ContentBlock] | dict[str, Any]` and leaked the internal +conversion shapes (a bare content sequence or a `(content, structured_content)` +tuple), forcing callers to re-assemble a `CallToolResult` themselves. + +If you call `MCPServer.call_tool()` directly, read `.content` and +`.structured_content` off the returned `CallToolResult` instead of branching on +the result type. + +### `MCPServer.get_prompt()` and `read_resource()` may return `InputRequiredResult` + +Like `call_tool()` above, `MCPServer.get_prompt()` now returns +`GetPromptResult | InputRequiredResult` and `MCPServer.read_resource()` returns +`Iterable[ReadResourceContents] | InputRequiredResult`: at 2026-07-28 an +`@mcp.prompt()` function or an `@mcp.resource()` template function may answer +with an `InputRequiredResult` to request client input first (see +[Multi-round-trip requests](handlers/multi-round-trip.md)). If you call these +methods directly, narrow with `isinstance` (or +`assert not isinstance(result, InputRequiredResult)` when your prompt and +resource functions never return one). `Prompt.render()` and +`ResourceTemplate.create_resource()` carry the same union. + +`ctx.read_resource()` inside a handler is unchanged: it still returns content, +and raises `RuntimeError` if the resource requests input. + +### `MCPServer.call_tool()`, `read_resource()`, `get_prompt()` now accept a `context` parameter + +`MCPServer.call_tool()`, `MCPServer.read_resource()`, and `MCPServer.get_prompt()` now accept an optional `context: Context | None = None` parameter. The framework passes this automatically during normal request handling. If you call these methods directly and omit `context`, a Context with no active request is constructed for you — tools that don't use `ctx` work normally, but any attempt to use `ctx.session`, `ctx.request_id`, etc. will raise. + +The internal layers (`ToolManager.call_tool`, `Tool.run`, `Prompt.render`, `ResourceTemplate.create_resource`, etc.) now require `context` as a positional argument. + +### Resolver-routed requests require the client capability on every protocol version + +A v1 server could send elicitation, sampling, and roots requests to clients +that never declared the matching capability; only tools-bearing sampling was +checked. In v2 the `Resolve(...)` markers (`Elicit`, `Sample`, `ListRoots`) +enforce the spec's egress rule: an undeclared capability (form-mode `elicitation`, +`sampling`, or `roots`, plus `sampling.tools` when the request carries `tools` +or `tool_choice`) fails the call with a `-32021` +`MISSING_REQUIRED_CLIENT_CAPABILITY` JSON-RPC error instead of sending a +request the client cannot handle. This applies on 2025-11-25 sessions with a +live back-channel too; a session with no back-channel keeps failing with its +no-back-channel error. To migrate, declare the capability: the SDK client +declares `elicitation`, `sampling`, and `roots` when the matching callback is +set, and `sampling.tools` needs an explicit +`Client(sampling_capabilities=SamplingCapability(tools=...))`. Direct +`ctx.elicit()` and `ctx.session.*` calls outside resolvers keep their previous +behavior, including the pre-existing tools check on `create_message`. + +### `MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error + +Raising `MCPError` (or any subclass) inside an `@mcp.tool()` handler now +produces a top-level JSON-RPC error response with the raised `code`, `message`, +and `data` intact. Previously the tool wrapper caught it like any other +exception and returned `CallToolResult(isError=True)`, which discarded the +error code and structured `data`. The one exception was +`UrlElicitationRequiredError`, which v1 already re-raised as a JSON-RPC error; +its behavior is unchanged. + +`MCPError` carries `ErrorData` and is the SDK's protocol-error type — raise it +when the request itself should be rejected (missing client capability, +elicitation required, invalid parameters). For tool *execution* failures the +calling LLM should see and react to, raise any other exception or return +`CallToolResult(is_error=True, ...)` directly; that path is unchanged. + +### Resource not found returns `-32602` and resource lookups raise typed exceptions (SEP-2164) + +Reading a missing resource now returns JSON-RPC error code `-32602` (invalid params) with the requested URI in `error.data` (`{"uri": ...}`), per [SEP-2164](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2164). Previously the server returned code `0` with no `data`. Clients can now reliably distinguish not-found from other errors; a template handler that raises `ResourceNotFoundError` (from `mcp.server.mcpserver.exceptions`) produces this same response. + +The underlying lookups now raise typed exceptions instead of `ValueError`. `ResourceManager.get_resource()` raises `ResourceNotFoundError` when no resource or template matches the URI, and `ResourceTemplate.create_resource()` raises `ResourceError` when the template function fails. Neither subclasses `ValueError`, so callers catching `ValueError` should switch to `ResourceNotFoundError` / `ResourceError` (both importable from `mcp.server.mcpserver.exceptions`; `ResourceNotFoundError` subclasses `ResourceError`). + +### Resource templates: matching behavior changes + +Resource template matching has been rewritten with [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) support. +Several behaviors have changed: + +**Path-safety checks applied by default.** Extracted parameter values +containing `..` as a path component, a null byte, or looking like an +absolute path (`/etc/passwd`, `C:\Windows`) now cause the read to +fail — the client receives an "Unknown resource" error and template +iteration stops, so a strict template's rejection does not fall +through to a later permissive template. This is checked on the +decoded value, so `..%2Fetc`, `%2E%2E`, and `%00` are caught too. +Note that `..` is only flagged as a standalone path component, so +values like `v1.0..v2.0` or `HEAD~3..HEAD` are unaffected. + +If a parameter legitimately needs to receive absolute paths or +traversal sequences, exempt it: + +```python +from mcp.server.mcpserver import ResourceSecurity + +@mcp.resource( + "inspect://file/{+target}", + security=ResourceSecurity(exempt_params={"target"}), +) +def inspect_file(target: str) -> str: ... +``` + +**Template literals and structural delimiters match exactly.** The +previous matcher built a regex without escaping, so `.` matched any +character and simple `{var}` swallowed `?`, `#`, `&`, and `,`. Now +`data://v1.0/{id}` no longer matches `data://v1X0/42`, and +`api://{id}` no longer matches `api://foo?x=1` — use `api://{id}{?x}` +to capture the query parameter. + +**`{var}` now matches an empty value.** A simple expression captures +zero or more characters, so `tickets://{ticket_id}` now matches +`tickets://` with `ticket_id=""` (v1.x's `[^/]+` regex required at +least one). This makes `match` round-trip `expand` for empty values — RFC 6570 +expands an empty string to nothing — but handlers that assumed a +non-empty value should validate it explicitly. + +**Template syntax errors surface at decoration time.** Unclosed +braces, duplicate variable names, and unsupported syntax raise +`InvalidUriTemplate` when the decorator runs rather than `re.error` +on first match. Two variables with no literal between them are also +rejected — matching cannot tell where one ends and the next begins — +so `{name}{+path}` raises. Write `{name}/{+path}`, or use an operator +that emits its own delimiter: `{+path}{.ext}` is fine because the `.` +operator contributes a literal `.` between the two. A handler +parameter bound to a query variable in the template's trailing +`{?...}`/`{&...}` run — the variables `match()` treats as optional, +listed by `UriTemplate.query_variable_names` — must declare a Python +default: a client may omit those, so a handler that requires one now +raises `ValueError` when the decorator runs instead of failing on the +first request that leaves it out. (A `{&...}` expression with no +preceding `{?...}` is not in that run: it is matched strictly, may +not be omitted, and needs no default.) + +**Static URIs with Context-only handlers now error.** A non-template +URI paired with a handler that takes only a `Context` parameter +previously registered but was silently unreachable (the resource +could never be read). This now raises `ValueError` at decoration time. +Context injection for static resources is not supported — use a +template with at least one variable or access context through other +means. + +See [URI templates](servers/uri-templates.md) for the full template syntax, +security configuration, and filesystem safety utilities. + +### `MCPServer`'s `Context` logging: `message` renamed to `data`, `extra` removed + +On the high-level `Context` object (`mcp.server.mcpserver.Context`), `log()`, `.debug()`, `.info()`, `.warning()`, and `.error()` now take `data: Any` instead of `message: str`, matching the MCP spec's `LoggingMessageNotificationParams.data` field which allows any JSON-serializable value. The `extra` parameter has been removed from the convenience-method signatures. Note that `extra` never worked at runtime in v1 (the kwargs were forwarded to `log()`, which did not accept them, raising `TypeError`), so this only affects code that type-checked but never exercised that path. Pass structured data directly as `data`. + +The lowlevel `ServerSession.send_log_message(data: Any)` already accepted arbitrary data and is unchanged. + +`Context.log()` also now accepts all eight [RFC 5424](https://datatracker.ietf.org/doc/html/rfc5424) log levels (`debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, `emergency`) via the `LoggingLevel` type, not just the four it previously allowed. + +```python +# Before +await ctx.info("Connection failed", extra={"host": "localhost", "port": 5432}) # extra= type-checked but raised TypeError at runtime in v1 +await ctx.log(level="info", message="hello") + +# After +await ctx.info({"message": "Connection failed", "host": "localhost", "port": 5432}) +await ctx.log(level="info", data="hello") +``` + +Positional calls (`await ctx.info("hello")`) are unaffected. + +### `ProgressContext` and `progress()` context manager removed + +The `mcp.shared.progress` module (`ProgressContext`, `Progress`, and the `progress()` context manager) has been removed. This module had no real-world adoption — all users send progress notifications via `Context.report_progress()` or `session.send_progress_notification()` directly. + +**Before (v1):** + +```python +from mcp.shared.progress import progress + +with progress(ctx, total=100) as p: + await p.progress(25) +``` + +**After — use `Context.report_progress()` (recommended):** + +```python +@mcp.tool() +async def my_tool(x: int, ctx: Context) -> str: + await ctx.report_progress(25, 100) + return "done" +``` + +**After — use `session.send_progress_notification()` (low-level):** + +```python +await session.send_progress_notification( + progress_token=progress_token, + progress=25, + total=100, +) +``` + +### `Context.elicit()` schema gate validates the rendered schema + +`Context.elicit()` (and `elicit_with_validation()`) now render the schema first and validate each property against the spec's `PrimitiveSchemaDefinition`, raising `TypeError` at the call site for anything outside it. `Optional[T]` fields render as `{"type": ...}` with the field omitted from `required` (previously the non-spec `anyOf` shape). A bare `list[str]` field is rejected because it renders without the required enum items; use `list[Literal[...]]` or `list[str]` with `json_schema_extra` supplying the items. Unions of multiple primitives (e.g. `int | str`) and nested models are rejected. + +A schema-mismatched *accepted* answer also fails differently: the call now raises `ValueError` with a stable message ("Received an accepted elicitation whose content does not match the requested schema") instead of letting pydantic's `ValidationError` escape with its internals. Code that caught `ValidationError` around `ctx.elicit()` should catch `ValueError` (or rely on the tool's error result). + +### `isinstance()` checks against `ElicitationResult` raise `TypeError` + +`ElicitationResult` is now a `TypeAliasType` instead of a plain union, so `ElicitationResult[Confirm]` works as an annotation (resolver dependency injection consumes it that way - see [Dependencies](handlers/dependencies.md)). The members are unchanged: `AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation`. + +The one behavioral change: a runtime `isinstance(result, ElicitationResult)` now raises `TypeError`. Check against the member classes directly instead: + +```python +result = await ctx.elicit("Proceed?", Confirm) +if isinstance(result, AcceptedElicitation): + ... # result.data is a Confirm +``` + +Narrowing on `result.action` (`"accept"` / `"decline"` / `"cancel"`) is unaffected. + +### Registering lowlevel handlers from `MCPServer` + +`MCPServer` does not expose public APIs for `subscribe_resource`, `unsubscribe_resource`, or `set_logging_level` handlers. In v1, the workaround was to reach into the private lowlevel server and use its decorator methods: + +**Before (v1):** + +```python +@mcp._mcp_server.set_logging_level() # pyright: ignore[reportPrivateUsage] +async def handle_set_logging_level(level: str) -> None: + ... + +mcp._mcp_server.subscribe_resource()(handle_subscribe) # pyright: ignore[reportPrivateUsage] +``` + +In v2, the lowlevel `Server` supports arbitrary request handlers directly via `add_request_handler` (the decorator methods are gone; handlers are otherwise constructor-only). From `MCPServer`, access it via `_lowlevel_server`: + +**After (v2):** + +```python +from mcp.server import ServerRequestContext +from mcp_types import EmptyResult, SetLevelRequestParams, SubscribeRequestParams + + +async def handle_set_logging_level(ctx: ServerRequestContext, params: SetLevelRequestParams) -> EmptyResult: + ... + return EmptyResult() + + +async def handle_subscribe(ctx: ServerRequestContext, params: SubscribeRequestParams) -> EmptyResult: + ... + return EmptyResult() + + +mcp._lowlevel_server.add_request_handler("logging/setLevel", SetLevelRequestParams, handle_set_logging_level) # pyright: ignore[reportPrivateUsage] +mcp._lowlevel_server.add_request_handler("resources/subscribe", SubscribeRequestParams, handle_subscribe) # pyright: ignore[reportPrivateUsage] +``` + +`_lowlevel_server` is private and may change. A public way to register these handlers on `MCPServer` is planned; until then, use this workaround or use the lowlevel `Server` directly. + +## Lowlevel Server + +### Lowlevel `Server`: decorator-based handlers replaced with constructor `on_*` params + +The lowlevel `Server` class no longer uses decorator methods for handler registration. Instead, handlers are passed as `on_*` keyword arguments to the constructor. + +**Before (v1):** + +```python +from mcp.server.lowlevel.server import Server +import mcp.types as types + +server = Server("my-server") + +@server.list_tools() +async def handle_list_tools(): + return [types.Tool(name="my_tool", description="A tool", inputSchema={})] + +@server.call_tool() +async def handle_call_tool(name: str, arguments: dict): + return [types.TextContent(type="text", text=f"Called {name}")] +``` + +**After (v2):** + +```python +from mcp.server import Server, ServerRequestContext +from mcp_types import ( + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) + +async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[Tool(name="my_tool", description="A tool", input_schema={"type": "object"})]) + + +async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + return CallToolResult( + content=[TextContent(type="text", text=f"Called {params.name}")], + is_error=False, + ) + +server = Server("my-server", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool) +``` + +**Key differences:** + +- Handlers receive `(ctx, params)` instead of the full request object or unpacked arguments. `ctx` is a `ServerRequestContext` with `session` and `lifespan_context` fields (plus `request_id`, `meta`, etc. for request handlers). `params` is the typed request params object. +- Handlers return the full result type (e.g. `ListToolsResult`) rather than unwrapped values (e.g. `list[Tool]`). +- The automatic `jsonschema` input/output validation that the old `call_tool()` decorator performed has been removed. There is no built-in replacement — if you relied on schema validation in the lowlevel server, you will need to validate inputs yourself in your handler. + +**Complete handler reference:** + +All handlers receive `ctx: ServerRequestContext` as the first argument. The second argument and return type are: + +| v1 decorator | v2 constructor kwarg | `params` type | return type | +|---|---|---|---| +| `@server.list_tools()` | `on_list_tools` | `PaginatedRequestParams \| None` | `ListToolsResult` | +| `@server.call_tool()` | `on_call_tool` | `CallToolRequestParams` | `CallToolResult` | +| `@server.list_resources()` | `on_list_resources` | `PaginatedRequestParams \| None` | `ListResourcesResult` | +| `@server.list_resource_templates()` | `on_list_resource_templates` | `PaginatedRequestParams \| None` | `ListResourceTemplatesResult` | +| `@server.read_resource()` | `on_read_resource` | `ReadResourceRequestParams` | `ReadResourceResult` | +| `@server.subscribe_resource()` | `on_subscribe_resource` | `SubscribeRequestParams` | `EmptyResult` | +| `@server.unsubscribe_resource()` | `on_unsubscribe_resource` | `UnsubscribeRequestParams` | `EmptyResult` | +| `@server.list_prompts()` | `on_list_prompts` | `PaginatedRequestParams \| None` | `ListPromptsResult` | +| `@server.get_prompt()` | `on_get_prompt` | `GetPromptRequestParams` | `GetPromptResult` | +| `@server.completion()` | `on_completion` | `CompleteRequestParams` | `CompleteResult` | +| `@server.set_logging_level()` | `on_set_logging_level` | `SetLevelRequestParams` | `EmptyResult` | +| — | `on_ping` | `RequestParams \| None` | `EmptyResult` | +| `@server.progress_notification()` | `on_progress` | `ProgressNotificationParams` | `None` | +| — | `on_roots_list_changed` | `NotificationParams \| None` | `None` | + +All `params` and return types are importable from `mcp_types`. + +**Notification handlers:** + +```python +from mcp.server import Server, ServerRequestContext +from mcp_types import ProgressNotificationParams + + +async def handle_progress(ctx: ServerRequestContext, params: ProgressNotificationParams) -> None: + print(f"Progress: {params.progress}/{params.total}") + +server = Server("my-server", on_progress=handle_progress) +``` + +Registering `on_progress` emits a deprecation warning because the 2026-07-28 spec deprecates client-to-server progress; see [Client-to-server progress deprecated (2026-07-28)](#client-to-server-progress-deprecated-2026-07-28). + +### Lowlevel `Server`: automatic return value wrapping removed + +The old decorator-based handlers performed significant automatic wrapping of return values. This magic has been removed — handlers now return fully constructed result types. If you want these conveniences, use `MCPServer` (previously `FastMCP`) instead of the lowlevel `Server`. + +**`call_tool()` — structured output wrapping removed:** + +The old decorator accepted several return types and auto-wrapped them into `CallToolResult`: + +```python +# Before (v1) — returning a dict auto-wrapped into structured_content + JSON TextContent +@server.call_tool() +async def handle(name: str, arguments: dict) -> dict: + return {"temperature": 22.5, "city": "London"} + +# Before (v1) — returning a list auto-wrapped into CallToolResult.content +@server.call_tool() +async def handle(name: str, arguments: dict) -> list[TextContent]: + return [TextContent(type="text", text="Done")] +``` + +```python +# After (v2) — construct the full result yourself +import json + +async def handle(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + data = {"temperature": 22.5, "city": "London"} + return CallToolResult( + content=[TextContent(type="text", text=json.dumps(data, indent=2))], + structured_content=data, + ) +``` + +Note: `params.arguments` can be `None` (the old decorator defaulted it to `{}`). Use `params.arguments or {}` to preserve the old behavior. + +**`read_resource()` — content type wrapping removed:** + +The old decorator auto-wrapped `Iterable[ReadResourceContents]` (and the deprecated `str`/`bytes` shorthand) into `TextResourceContents`/`BlobResourceContents`, handling base64 encoding and mime-type defaulting: + +```python +# Before (v1) — Iterable[ReadResourceContents] auto-wrapped +from mcp.server.lowlevel.helper_types import ReadResourceContents + +@server.read_resource() +async def handle(uri: AnyUrl) -> Iterable[ReadResourceContents]: + return [ReadResourceContents(content="file contents", mime_type="text/plain")] + +# Before (v1) — str/bytes shorthand (already deprecated in v1) +@server.read_resource() +async def handle(uri: str) -> str: + return "file contents" + +@server.read_resource() +async def handle(uri: str) -> bytes: + return b"\x89PNG..." +``` + +```python +# After (v2) — construct TextResourceContents or BlobResourceContents yourself +import base64 + +async def handle_read(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult: + # Text content + return ReadResourceResult( + contents=[TextResourceContents(uri=str(params.uri), text="file contents", mime_type="text/plain")] + ) + +async def handle_read(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult: + # Binary content — you must base64-encode it yourself + return ReadResourceResult( + contents=[BlobResourceContents( + uri=str(params.uri), + blob=base64.b64encode(b"\x89PNG...").decode("utf-8"), + mime_type="image/png", + )] + ) +``` + +**`list_tools()`, `list_resources()`, `list_prompts()` — list wrapping removed:** + +The old decorators accepted bare lists and wrapped them into the result type: + +```python +# Before (v1) +@server.list_tools() +async def handle() -> list[Tool]: + return [Tool(name="my_tool", ...)] + +# After (v2) +async def handle(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[Tool(name="my_tool", ...)]) +``` + +**Using `MCPServer` instead:** + +If you prefer the convenience of automatic wrapping, use `MCPServer` which still provides these features through its `@mcp.tool()`, `@mcp.resource()`, and `@mcp.prompt()` decorators. The lowlevel `Server` is intentionally minimal — it provides no magic and gives you full control over the MCP protocol types. + +### Lowlevel `Server`: tool handler exceptions no longer become `CallToolResult(is_error=True)` + +The v1 `@server.call_tool()` decorator caught any exception raised by the handler and returned it to the client as an error-flagged tool result (`isError: true`), so the calling LLM saw the error text as a tool result and could self-correct. In v2, `on_call_tool` is registered with no exception wrapping: a non-`MCPError` exception propagates to the dispatcher and is answered as a top-level JSON-RPC **error response** with `code=0` and `message=str(exc)`. Typical clients (including the SDK's own) raise on a protocol error instead of returning a result, so the error text is no longer LLM-visible. The server also logs a `handler for 'tools/call' raised` traceback that v1 never emitted. + +**Before (v1):** + +```python +@server.call_tool() +async def call_tool(name: str, arguments: dict): + raise ValueError("kaboom") # client receives CallToolResult(isError=True) +``` + +**After (v2):** catch exceptions in the handler and build the error result yourself: + +```python +from mcp.server import Server +from mcp_types import CallToolResult, TextContent + + +async def handle_call_tool(ctx, params) -> CallToolResult: + try: + ... # tool logic + except Exception as e: + return CallToolResult( + content=[TextContent(type="text", text=str(e))], + is_error=True, + ) + + +server = Server("my-server", on_call_tool=handle_call_tool) +``` + +Raise `MCPError` only when the request itself should be rejected as a protocol error; that path is deliberate in v2. Alternatively, use `MCPServer`, whose `@mcp.tool()` wrapper still converts generic exceptions into `is_error=True` results (see [`MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error](#mcperror-raised-from-an-mcptool-handler-now-surfaces-as-a-json-rpc-error)). + +### Lowlevel `Server`: constructor parameters are now keyword-only + +All parameters after `name` are now keyword-only. If you were passing `version` or other parameters positionally, use keyword arguments instead: + +```python +# Before (v1) +server = Server("my-server", "1.0") + +# After (v2) +server = Server("my-server", version="1.0") +``` + +### Lowlevel `Server`: type parameter reduced from 2 to 1 + +The `Server` class previously had two type parameters: `Server[LifespanResultT, RequestT]`. The `RequestT` parameter has been removed. In v1 it typed the transport-level request object exposed as `server.request_context.request`, not anything handlers received directly. + +```python +# Before (v1) +from typing import Any + +from mcp.server import Server + +server: Server[dict[str, Any], Any] = Server(...) + +# After (v2) +from typing import Any + +from mcp.server import Server + +server: Server[dict[str, Any]] = Server(...) +``` + +### Lowlevel `Server`: `request_handlers` and `notification_handlers` attributes removed + +The public `server.request_handlers` and `server.notification_handlers` dictionaries have been removed. Handler registration is now done through constructor `on_*` keyword arguments, or through the public `add_request_handler` / `add_notification_handler` methods. + +```python +# Before (v1) — direct dict access +from mcp.types import ListToolsRequest + +server.request_handlers[ListToolsRequest] = handle_list_tools + +if ListToolsRequest in server.request_handlers: + ... + +# After (v2) — no public access to handler dicts +server = Server("my-server", on_list_tools=handle_list_tools) + +if server.get_request_handler("tools/list") is not None: + ... +``` + +If you need to check whether a handler is registered, use `server.get_request_handler(method)` or `server.get_notification_handler(method)`, which return the registered entry or `None`. Note the lookup key is now the method string (for example `"tools/list"`), not the request type. + +### Lowlevel `Server`: `subscribe` capability now correctly reported + +Previously, the lowlevel `Server` hardcoded `subscribe=False` in resource capabilities even when a `subscribe_resource()` handler was registered. The `subscribe` capability is now dynamically set to `True` when an `on_subscribe_resource` handler is provided. Clients that previously didn't see `subscribe: true` in capabilities will now see it when a handler is registered, which may change client behavior. + +### Lowlevel `Server`: private `_handle_*` dispatch methods removed + +`Server._handle_message`, `_handle_request`, and `_handle_notification` have been removed. The receive loop and per-message dispatch now live in `JSONRPCDispatcher` and `ServerRunner`, which `Server.run()` drives internally. + +These were private, but some users subclassed `Server` and overrode them to intercept requests. Use middleware instead: + +```python +from typing import Any + +from mcp.server import Server, ServerRequestContext +from mcp.server.context import CallNext, HandlerResult + + +async def logging_middleware(ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult: + print(f"handling {ctx.method}") + result = await call_next(ctx) + print(f"done {ctx.method}") + return result + + +server = Server("my-server", on_call_tool=...) +server.middleware.append(logging_middleware) +``` + +The method and the raw inbound params are `ctx.method` and `ctx.params` (`params` is `None` when the message carries none). Middleware runs before params validation and also wraps unknown methods. To rewrite the method or params before the handler runs, pass an adjusted context through: `await call_next(replace(ctx, params=...))`. + +### Lowlevel `Server.run(raise_exceptions=True)`: transport errors no longer re-raised + +`raise_exceptions=True` now only governs handler exceptions: an exception raised by an `on_*` handler propagates out of `run()`. The JSON-RPC error response is still written to the client first, regardless of the flag. + +Previously it also re-raised exceptions yielded by the transport onto the read stream (e.g. JSON parse errors). Those are now debug-logged and dropped regardless of `raise_exceptions`. If you relied on `run()` exiting on a transport-level parse error, that no longer happens. + +### `Server.run()` no longer takes a `stateless` flag + +The `stateless: bool` parameter on the lowlevel `Server.run()` has been removed. Stateless serving is now a property of how the connection is constructed (the streamable-HTTP manager builds a born-ready `Connection` per request), not a flag the loop driver inspects. + +Server-initiated requests that have no channel to travel on now raise `NoBackChannelError` (an `MCPError` subclass) — the same exception regardless of why the channel is absent. In v1 there was no dedicated exception for this case: the transport silently dropped the outbound message and the awaiting call stalled. + +### Lowlevel `Server`: `request_context` property removed + +The `server.request_context` property has been removed. Request context is now passed directly to handlers as the first argument (`ctx`). The `request_ctx` module-level contextvar has been removed entirely. + +**Before (v1):** + +```python +from mcp.server.lowlevel.server import request_ctx + +@server.call_tool() +async def handle_call_tool(name: str, arguments: dict): + ctx = server.request_context # or request_ctx.get() + await ctx.session.send_log_message(level="info", data="Processing...") + return [types.TextContent(type="text", text="Done")] +``` + +**After (v2):** + +```python +from mcp.server import ServerRequestContext +from mcp_types import CallToolRequestParams, CallToolResult, TextContent + + +async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + await ctx.session.send_log_message(level="info", data="Processing...") + return CallToolResult( + content=[TextContent(type="text", text="Done")], + is_error=False, + ) +``` + +### `RequestContext` type parameters simplified + +`RequestContext` has been removed from `mcp.shared.context` (importing it now raises `ImportError`; the module now holds an unrelated internal class). It is split into `ClientRequestContext` (in `mcp.client.context`) and `ServerRequestContext` (in `mcp.server.context`). + +**`RequestContext` changes:** + +- The `RequestContext[SessionT, LifespanContextT, RequestT]` generic no longer exists; use `ClientRequestContext` or `ServerRequestContext[LifespanContextT, RequestT]` +- Server-specific fields (`lifespan_context`, `request`, `close_sse_stream`, `close_standalone_sse_stream`) moved to new `ServerRequestContext` class in `mcp.server.context` + +**Before (v1):** + +```python +from mcp.client.session import ClientSession +from mcp.shared.context import RequestContext, LifespanContextT, RequestT + +# RequestContext with 3 type parameters +ctx: RequestContext[ClientSession, LifespanContextT, RequestT] +``` + +**After (v2):** + +```python +from mcp.client.context import ClientRequestContext +from mcp.server.context import ServerRequestContext, LifespanContextT, RequestT + +# For client-side context (sampling, elicitation, list_roots callbacks) +ctx: ClientRequestContext + +# For server-specific context with lifespan and request types +server_ctx: ServerRequestContext[LifespanContextT, RequestT] +``` + +`ServerRequestContext` is a standalone dataclass rather than a specialization of a shared base class. It carries the same fields (`session`, `request_id`, `meta`, `lifespan_context`, `request`, `close_sse_stream`, `close_standalone_sse_stream`) plus new `protocol_version: str`, `method: str`, and raw `params: Mapping[str, Any] | None` fields, so handler code is mostly unaffected, but `isinstance(ctx, RequestContext)` checks and `RequestContext[ServerSession]` annotations need updating to `ServerRequestContext`. + +One field is newly optional: `request_id` is now `RequestId | None` (in v1 it was always a `RequestId`). The same context class is passed to notification handlers, where `request_id` is `None`, so code that forwards `ctx.request_id` as a definite `RequestId` needs a `None` check to satisfy type checkers. + +The high-level `Context` class (injected into `@mcp.tool()` etc.) similarly dropped its `ServerSessionT` parameter: `Context[ServerSessionT, LifespanContextT, RequestT]` → `Context[LifespanContextT, RequestT]`. Both remaining parameters have defaults, so bare `Context` is usually sufficient: + +**Before (v1):** + +```python +async def my_tool(ctx: Context[ServerSession, None]) -> str: ... +``` + +**After (v2):** + +```python +async def my_tool(ctx: Context) -> str: ... +# or, with an explicit lifespan type: +async def my_tool(ctx: Context[MyLifespanState]) -> str: ... +``` + +### `ServerSession` is now a thin proxy (no longer a `BaseSession`) + +`ServerSession` no longer subclasses `BaseSession`. It is now a small per-request proxy that exposes `send_request`, `send_notification`, the typed convenience helpers (`create_message`, `elicit_form`, `send_log_message`, `send_tool_list_changed`, ...), `client_params`, `protocol_version`, and `check_client_capability`. The receive loop, `initialize` handling, and per-request task isolation that previously lived in `ServerSession` have moved to `JSONRPCDispatcher` and `ServerRunner`. + +`ServerSession` is normally constructed for you by `Server.run()` and reached via `ctx.session` in handlers, so most servers are unaffected. If you were constructing or subclassing it directly: + +**Constructor change:** + +```python +# Before (v1) +session = ServerSession(read_stream, write_stream, init_options, stateless=False) + +# After (v2) +session = ServerSession(request_outbound, connection) +# where `request_outbound` is a DispatchContext and `connection` is a Connection +``` + +In practice, replace direct `ServerSession` use with `Server.run(read_stream, write_stream, init_options)` and let the framework wire it up. + +**Removed from `mcp.server.session`:** + +- `InitializationState` enum and `ServerSession._initialization_state` — initialization tracking is now on `Connection` (`connection.initialized` is an `anyio.Event`, `connection.client_params` holds the init params). +- `ServerRequestResponder` type alias. +- `ServerSession.incoming_messages` stream — there is no longer a public stream of inbound messages to iterate. Register handlers via the `on_*` constructor params (or `add_request_handler`) and use `Server.middleware` to observe every inbound request and notification (`initialize`, unknown methods, validation failures, and `notifications/initialized` included). +- `ServerSession.__aenter__` / `__aexit__` — `ServerSession` is no longer an async context manager. +- The private `_receive_loop`, `_received_request`, `_received_notification`, and `_handle_incoming` overrides — there is nothing to override on `ServerSession` anymore. To intercept inbound messages, use `Server.middleware` (see [Lowlevel `Server`: private `_handle_*` dispatch methods removed](#lowlevel-server-private-_handle_-dispatch-methods-removed)). + +### `ServerSession.elicit()` and `elicit_form()` take `requested_schema`, not `requestedSchema` + +The schema parameter of `ServerSession.elicit()` and `ServerSession.elicit_form()` was renamed from `requestedSchema` to `requested_schema`. This is a plain method parameter, so the `populate_by_name` alias support that keeps camelCase field names working on Pydantic models does not apply here. Keyword callers raise on every call, before any wire traffic (nothing fails at import; the client sees a tool error): + +```text +TypeError: ServerSession.elicit_form() got an unexpected keyword argument 'requestedSchema'. Did you mean 'requested_schema'? +``` + +**Before (v1):** + +```python +result = await ctx.session.elicit_form( + message="Your name?", + requestedSchema={ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + }, +) +``` + +**After (v2):** + +```python +result = await ctx.session.elicit_form( + message="Your name?", + requested_schema={ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + }, +) +``` + +Positional callers (`session.elicit_form(message, schema)`) are unaffected. `elicit_url()` already used snake_case parameters in v1; only `elicit()` and `elicit_form()` changed. + +## Clients + +### `Client` defaults to `mode='auto'` + +In v1, connecting to a server always performed the `initialize` handshake. In v2, `Client` defaults to `mode='auto'`: on enter it probes `server/discover` and, if the server doesn't support it, falls back to the `initialize` handshake. Pass `mode='legacy'` to force the initialize handshake and reproduce v1's pre-2026 connection sequence (the per-request wire shape still differs from v1; see [Every outbound request now carries a `_meta` envelope](#every-outbound-request-now-carries-a-_meta-envelope-opentelemetry-is-on-by-default)), or pass a modern protocol-version string (e.g. `mode='2026-07-28'`) to pin a version without probing. + +The probe is transport-independent: v2 servers answer it over stdio (and any other stream-pair transport) as well as streamable HTTP, so `mode='auto'` lands on `2026-07-28` against a v2 server on every transport. If your stdio workflow relies on server-initiated requests (sampling, push elicitation), pass `mode='legacy'` — a 2026-07-28 connection refuses them on every transport. + +For an in-process `Client(server)` (where `server` is a `Server` or `MCPServer` instance), `mode='auto'` dispatches calls directly through `DirectDispatcher` with no JSON-RPC framing. Pass `mode='legacy'` if you need the in-memory JSON-RPC transport that v1 used. + +`Client.send_ping()` is deprecated (ping is removed in 2026-07-28); pin `mode='legacy'` if you need it. + +### `ClientSession.get_server_capabilities()` replaced by era-neutral accessors + +`ClientSession` now exposes the negotiated server metadata as properties: `server_capabilities`, `server_info`, `instructions`, and `protocol_version`. These are populated by whichever connection step ran (`initialize()` for ≤2025-11-25 servers, `discover()` for 2026-07-28+), and are `None` if none has — matching v1's `get_server_capabilities()`. The `get_server_capabilities()` method has been removed. + +**Before (v1):** + +```python +capabilities = session.get_server_capabilities() +# server_info, instructions, protocol_version were not stored — had to capture initialize() return value +``` + +**After (v2):** + +```python +capabilities = session.server_capabilities +server_info = session.server_info +instructions = session.instructions +version = session.protocol_version +``` + +The raw handshake result is also retained: `session.initialize_result` is set after `initialize()` (≤2025-11-25 servers — including `stateless_http=True` servers, which still answer `initialize`); `session.discover_result` is set after `discover()` (2026-07-28+ servers). At most one is non-`None`. + +On the high-level `Client`, `client.server_capabilities`, `client.server_info`, and `client.protocol_version` are non-nullable inside the context manager. `client.instructions` remains `str | None` since the server may omit it. (The lowlevel `ClientSession` still lets you call methods before any handshake, as in v1; `Client` always connects on enter — by default it probes `server/discover` and falls back to the initialize handshake.) + +### `cursor` parameter removed from `ClientSession` list methods + +The deprecated `cursor` parameter has been removed from the following `ClientSession` methods: + +- `list_resources()` +- `list_resource_templates()` +- `list_prompts()` +- `list_tools()` + +Use `params=PaginatedRequestParams(cursor=...)` instead. + +**Before (v1):** + +```python +result = await session.list_resources(cursor="next_page_token") +result = await session.list_tools(cursor="next_page_token") +``` + +**After (v2):** + +```python +from mcp_types import PaginatedRequestParams + +result = await session.list_resources(params=PaginatedRequestParams(cursor="next_page_token")) +result = await session.list_tools(params=PaginatedRequestParams(cursor="next_page_token")) +``` + +### `args` parameter removed from `ClientSessionGroup.call_tool()` + +The deprecated `args` parameter has been removed from `ClientSessionGroup.call_tool()`. Use `arguments` instead. + +**Before (v1):** + +```python +result = await session_group.call_tool("my_tool", args={"key": "value"}) +``` + +**After (v2):** + +```python +result = await session_group.call_tool("my_tool", arguments={"key": "value"}) +``` + +### Timeouts take `float` seconds instead of `timedelta` + +Every timeout parameter that took a `datetime.timedelta` in v1 now takes plain seconds as a `float`: + +| Surface | v1 type | v2 type | +|---|---|---| +| `ClientSession(read_timeout_seconds=...)` | `timedelta \| None` | `float \| None` | +| `ClientSession.call_tool(read_timeout_seconds=...)` | `timedelta \| None` | `float \| None` | +| `ClientSession.send_request(request_read_timeout_seconds=...)` | `timedelta \| None` | `float \| None` | +| `ClientSessionGroup.call_tool(read_timeout_seconds=...)` | `timedelta \| None` | `float \| None` | +| `ClientSessionParameters.read_timeout_seconds` | `timedelta \| None` | `float \| None` | +| `StreamableHttpParameters.timeout` / `.sse_read_timeout` | `timedelta` | `float` | +| `ServerSession.send_request(request_read_timeout_seconds=...)` | `timedelta \| None` | `float \| None` | + +`SseServerParameters` already used `float` in v1 and is unaffected. + +**Before (v1):** + +```python +from datetime import timedelta + +session = ClientSession(read_stream, write_stream, read_timeout_seconds=timedelta(seconds=30)) +result = await session.call_tool("slow_tool", {}, read_timeout_seconds=timedelta(minutes=2)) + +params = StreamableHttpParameters( + url="https://example.com/mcp", + timeout=timedelta(seconds=30), + sse_read_timeout=timedelta(seconds=300), +) +``` + +**After (v2):** + +```python +session = ClientSession(read_stream, write_stream, read_timeout_seconds=30) +result = await session.call_tool("slow_tool", {}, read_timeout_seconds=120) + +params = StreamableHttpParameters( + url="https://example.com/mcp", + timeout=30, + sse_read_timeout=300, +) +``` + +The failure mode depends on the surface. `StreamableHttpParameters` is a pydantic model, so a leftover timedelta fails loudly at construction (`ValidationError: Input should be a valid number`). The session-path parameters still accept the timedelta at construction or call time; the first request that arms the timeout then crashes inside anyio with `TypeError: unsupported operand type(s) for +: 'float' and 'datetime.timedelta'`, an error that never names the parameter. One narrowing note: v1's `StreamableHttpParameters` coerced bare numbers into timedelta, so v1 code that already passed numbers there keeps working; only explicit-timedelta code breaks. + +The same change applies server-side: `ServerSession.send_request(request_read_timeout_seconds=...)`, called from a lowlevel handler via `ctx.session`, is now `float | None`. A v1-style timedelta raises the same anyio `TypeError`, after the request has already been written to the wire, so the handler crashes instead of receiving the response. + +To migrate, replace `timedelta(...)` with plain seconds, or mechanically append `.total_seconds()` to an existing timedelta value. + +### Client request timeouts now raise `-32001` (`REQUEST_TIMEOUT`) instead of `408` + +A client request that exceeds `read_timeout_seconds` still raises the SDK's protocol error (`MCPError`, previously `McpError`), but the error code changed from the HTTP status `408` (`httpx.codes.REQUEST_TIMEOUT`) to the JSON-RPC code `-32001` (`REQUEST_TIMEOUT`, importable from `mcp_types`), matching the TypeScript SDK. The message changed too: v1 said `"Timed out while waiting for response to ClientRequest. Waited 5.0 seconds."`, v2 says `"Request 'tools/call' timed out"`. `MCPError.error` still exists, so a migrated `e.error.code == 408` check runs without error and silently never matches; timeouts fall through to whatever generic-error handling follows. Code that matched on the old message text breaks too. Compare against `REQUEST_TIMEOUT` instead. + +**Before (v1):** + +```python +import httpx +from mcp.shared.exceptions import McpError + +try: + result = await session.call_tool("slow_tool", {}) +except McpError as e: + if e.error.code == httpx.codes.REQUEST_TIMEOUT: # 408 + ... # retry / back off + else: + raise +``` + +**After (v2):** + +```python +from mcp.shared.exceptions import MCPError +from mcp_types import REQUEST_TIMEOUT # -32001 + +try: + result = await client.call_tool("slow_tool", {}) +except MCPError as e: + if e.code == REQUEST_TIMEOUT: + ... # retry / back off + else: + raise +``` + +`e.error.code` also still works; `e.code` is the v2 convenience property. `mcp.types` no longer exists, so the constant comes from `mcp_types`. The example uses the high-level `Client`; `ClientSession.call_tool()` raises the same `MCPError`. + +### `ClientSession` now runs on `JSONRPCDispatcher`; `BaseSession` removed + +`ClientSession`'s public surface is unchanged — same constructor apart from timeout parameters (see [Timeouts take `float` seconds instead of `timedelta`](#timeouts-take-float-seconds-instead-of-timedelta)), typed methods, manual `initialize()`, and async context-manager lifecycle — but `BaseSession`, the v1 receive loop underneath it, is removed with no shim. The engine now lives in `JSONRPCDispatcher` (`mcp.shared.jsonrpc_dispatcher`). To customize client behavior, use the `ClientSession` constructor callbacks, or pass a pre-built dispatcher via the new keyword-only `dispatcher=` constructor argument (e.g. a `DirectDispatcher` for in-process embedding). + +Behavior changes: + +- **Callbacks and notifications now run concurrently.** In v1 the receive loop processed one inbound message at a time, so callbacks ran inline and in order. Now each delivery starts in arrival order but runs as its own task. Server-initiated request callbacks (`sampling`, `elicitation`, `roots`) no longer block other traffic, may themselves send requests without deadlocking, and are interrupted if the server sends `notifications/cancelled` (the request is then answered with an error). Notification callbacks (`logging_callback`, `progress_callback`, `message_handler`) may interleave, and a `progress_callback` may run after the request it reports on has returned; there is no built-in bound on concurrent deliveries. Transport-level errors reach `message_handler` the same way, and a `message_handler` that raises is logged rather than fatal to the session. Callbacks that need strict sequencing must coordinate themselves. +- **Timeouts**: a timed-out or abandoned request is now followed by `notifications/cancelled`, so the server stops the handler instead of leaving it running. +- **A raising request callback** is answered with `code=0` and the exception text; v1 flattened every callback exception to `INVALID_PARAMS`. For a specific error response, return `ErrorData` (unchanged) or raise `MCPError`. One carve-out: pydantic's `ValidationError` is still answered with `INVALID_PARAMS`, as in v1. +- **`send_request` before entering the context manager** raises `RuntimeError` immediately; v1 wrote to the transport and hung until the timeout. After the connection has closed it raises `MCPError` (`CONNECTION_CLOSED`) instead. `send_notification` before entry still works. +- **`send_notification` after the connection has closed is dropped with a debug log instead of raising.** In v1 the send raised `anyio.BrokenResourceError` (peer gone) or `anyio.ClosedResourceError` (session torn down), and this applied to the typed helpers (`send_roots_list_changed`, `send_progress_notification`) too. Code that used the exception as its disconnect signal should probe with a request instead (`send_request` still raises `MCPError` after close, see above) or scope the sending task to the session's lifetime. +- **`send_notification` no longer takes `related_request_id`, and `send_request` no longer accepts `ServerMessageMetadata`.** No client transport ever serialized these hints; progress and response correlation via `progressToken` and the request id is unaffected. +- **Client callbacks now receive `mcp.client.ClientRequestContext`** (its `request_id` is always populated); the `mcp.shared.context.RequestContext` generic is deleted. Annotations spelled `RequestContext[ClientSession, Any]` become `ClientRequestContext` (details in [`RequestContext` type parameters simplified](#requestcontext-type-parameters-simplified)). + +`mcp.shared.session` is now a compatibility module: `ProgressFnT` is re-exported (its home is `mcp.shared.dispatcher`), and `RequestResponder` remains as a typing-only stub so `MessageHandlerFnT` annotations keep importing. `RequestResponder.respond()` no longer exists, and neither do the cancellation-tracking members (`cancel()`, the `cancelled` and `in_flight` properties, the `on_complete` constructor argument) or `BaseSession._in_flight`; inbound cancellation is handled by `JSONRPCDispatcher`. + +### Experimental Tasks support removed + +Tasks ([SEP-1686](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1686)) have been removed from the MCP specification and are no longer part of this SDK. The `mcp.client.experimental`, `mcp.server.experimental`, `mcp.shared.experimental`, and `mcp.server.lowlevel.experimental` modules have been removed, along with the `experimental` properties on `ClientSession`, `ServerSession`, `Server`, and `ServerRequestContext`. The corresponding `Task*` types remain in `mcp_types` as types-only definitions, except the `TaskExecutionMode` alias, whose literal is now inlined on `ToolExecution.task_support`. + +The 2026-07-28 revision reintroduces Tasks as an official extension: [SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663), `io.modelcontextprotocol/tasks`, redesigned around polling (`tasks/get`) instead of a blocking `tasks/result`. This SDK does not implement the extension yet. + +## Transports + +### `streamablehttp_client` removed + +The deprecated `streamablehttp_client` function has been removed. Use `streamable_http_client` instead. + +**Before (v1):** + +```python +from mcp.client.streamable_http import streamablehttp_client + +async with streamablehttp_client( + url="http://localhost:8000/mcp", + headers={"Authorization": "Bearer token"}, + timeout=30, + sse_read_timeout=300, + auth=my_auth, +) as (read_stream, write_stream, get_session_id): + ... +``` + +**After (v2):** + +```python +import httpx +from mcp.client.streamable_http import streamable_http_client + +# Configure headers, timeout, and auth on the httpx.AsyncClient +http_client = httpx.AsyncClient( + headers={"Authorization": "Bearer token"}, + timeout=httpx.Timeout(30, read=300), + auth=my_auth, + follow_redirects=True, +) + +async with http_client: + async with streamable_http_client( + url="http://localhost:8000/mcp", + http_client=http_client, + ) as (read_stream, write_stream): + ... +``` + +v1's internal client set `follow_redirects=True`; set it explicitly when supplying your own `httpx.AsyncClient` to preserve that behavior. + +### `get_session_id` callback removed from `streamable_http_client` + +The `get_session_id` callback (third element of the returned tuple) has been removed from `streamable_http_client`. The function now returns a 2-tuple `(read_stream, write_stream)` instead of a 3-tuple. + +The `GetSessionIdCallback` type alias is gone as well, so `from mcp.client.streamable_http import GetSessionIdCallback` now raises `ImportError`. Drop the annotation, or inline `Callable[[], str | None]` if your own wrapper code still needs the type. + +If you need to capture the session ID (e.g., for session resumption testing), you can use httpx event hooks to capture it from the response headers: + +**Before (v1):** + +```python +from mcp.client.streamable_http import streamable_http_client + +async with streamable_http_client(url) as (read_stream, write_stream, get_session_id): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + session_id = get_session_id() # Get session ID via callback +``` + +**After (v2):** + +```python +import httpx +from mcp.client.streamable_http import streamable_http_client + +# Option 1: Simply ignore if you don't need the session ID +async with streamable_http_client(url) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + +# Option 2: Capture session ID via httpx event hooks if needed +captured_session_ids: list[str] = [] + +async def capture_session_id(response: httpx.Response) -> None: + session_id = response.headers.get("mcp-session-id") + if session_id: + captured_session_ids.append(session_id) + +http_client = httpx.AsyncClient( + event_hooks={"response": [capture_session_id]}, + follow_redirects=True, +) + +async with http_client: + async with streamable_http_client(url, http_client=http_client) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + session_id = captured_session_ids[0] if captured_session_ids else None +``` + +### `StreamableHTTPTransport` parameters removed + +The `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters have been removed from `StreamableHTTPTransport`. Configure these on the `httpx.AsyncClient` instead (see example above). + +Note: `sse_client` retains its `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters — only the streamable HTTP transport changed. + +### `StreamableHTTPTransport.protocol_version` attribute removed + +The transport no longer holds per-connection protocol state; era-dependent headers (e.g. `MCP-Protocol-Version`) are now supplied per-message by the session. If you were reading `transport.protocol_version` to learn the negotiated version, read `session.protocol_version` (or `client.protocol_version` on the high-level `Client`) instead. + +The `MCP_PROTOCOL_VERSION` header-name constant has moved: import `MCP_PROTOCOL_VERSION_HEADER` from `mcp.shared.inbound` instead of `MCP_PROTOCOL_VERSION` from `mcp.client.streamable_http`. + +### Streamable HTTP: non-2xx responses now surface as per-request JSON-RPC errors + +In v1, a non-2xx response to a message POST (other than 404) raised `httpx.HTTPStatusError` inside the transport's task group, so it escaped the `streamable_http_client` context as an `ExceptionGroup` and failed every pending request; a 404 raised `McpError` with the positive literal code `32600`. In v2 the transport no longer raises for HTTP status errors: the failing request gets a JSON-RPC error, raised as `MCPError` from that one call, and the connection stays usable. After a 500 fails one `tools/list`, the next call on the same session succeeds. + +| Server response | v1 | v2 | +| --- | --- | --- | +| Non-2xx with a JSON-RPC error body | body discarded; `httpx.HTTPStatusError` escapes the context | body's error surfaced verbatim, e.g. `MCPError(-32602, 'Invalid params')` | +| 404, session established | `McpError` with positive code `32600` | `MCPError(-32600, 'Session terminated')` | +| 404, no session yet | `McpError` with positive code `32600` | `MCPError(-32601, 'Not Found')` | +| Any other 4xx/5xx | `httpx.HTTPStatusError` escapes as `ExceptionGroup` | `MCPError(-32603, 'Server returned an error response')` | + +Both common v1 patterns silently stop working: an `except* httpx.HTTPStatusError` around the transport context becomes dead code because status errors no longer escape the context, and a session-expiry check on `error.code == 32600` never matches again because the code is now the standard negative `-32600`. + +**Before (v1):** + +```python +import httpx +from mcp.shared.exceptions import McpError + +while True: + try: + async with streamable_http_client(url) as (read, write, _get_id): + async with ClientSession(read, write) as session: + await session.initialize() + try: + await session.list_tools() + except McpError as exc: + if exc.error.code == 32600: # v1's "Session terminated" + continue # session expired: rebuild the connection + raise + except* httpx.HTTPStatusError: + pass # server returned 4xx/5xx: the loop rebuilds the connection +``` + +**After (v2):** + +```python +from mcp import ClientSession, MCPError +from mcp.client.streamable_http import streamable_http_client +from mcp_types import INVALID_REQUEST # -32600 + +async with streamable_http_client(url) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + try: + await session.list_tools() + except MCPError as exc: + if exc.code == INVALID_REQUEST and exc.message == "Session terminated": + await reconnect() # session expired: rebuild the connection + else: + raise +``` + +Move HTTP-status failure handling from around the transport context to around the individual calls, catching `MCPError` (see [`McpError` renamed to `MCPError`](#mcperror-renamed-to-mcperror)). Connect-level failures such as `httpx.ConnectError` still escape the transport context as before; keep context-level handling for those only. + +### `terminate_windows_process` removed + +The deprecated `mcp.os.win32.utilities.terminate_windows_process` function has been +removed. Process termination is handled internally by the `stdio_client` context +manager; there is no replacement API. The Windows tree-termination helper +`terminate_windows_process_tree` no longer accepts a `timeout_seconds` argument — +the value was never used (Job Object termination is immediate). + +### `stdio_client` shutdown reworked: a gracefully-exited server's children are left alive on POSIX + +When a server exits on its own after `stdio_client` closes its stdin, background +child processes the server leaves behind are deliberately left alive on POSIX: +their lifetime is the server's business. The old shutdown wait was gated on the +stdio pipes closing rather than on process exit, so a child holding an inherited +pipe made a well-behaved server look hung: shutdown stalled for the full grace +period, then attempted a tree-kill that in practice failed against the +already-exited server (its process group could no longer be looked up) and logged +a warning, leaving the children alive anyway. (That gating is an asyncio behavior +specific to Python 3.11+; on Python 3.10 and the trio backend the old wait already +resolved on process exit, so the spurious stall never happened there.) A server that does not exit within the grace +period is still terminated +along with its entire process group. On Windows, children stay in the server's Job +Object and are still killed at shutdown — now deterministically when the job handle +is closed, rather than whenever the handle happened to be garbage-collected. + +If you relied on `stdio_client` killing everything the server spawned, make the +server terminate its own children on shutdown (its stdin reaching EOF is the +shutdown signal), or clean up the process tree from the host application after +`stdio_client` exits. + +Two related shutdown refinements: `stdio_client` now closes its end of the pipes +deterministically at shutdown, so a surviving child that keeps writing to an +inherited stdout receives `EPIPE`/`SIGPIPE` once the client is gone (previously the +pipe lingered until garbage collection); and a failed write to a server that is +still running now surfaces as a closed connection (`CONNECTION_CLOSED`) on the read +side instead of a raw `BrokenResourceError` escaping the `stdio_client` context. + +`terminate_posix_process_tree` now requires the process to lead its own process +group (spawned with `start_new_session=True`); the `getpgid()` lookup and the +per-process terminate/kill fallback are gone. The win32 utilities logger is now +named `mcp.os.win32.utilities` (was `client.stdio.win32`). + +### WebSocket transport removed + +The WebSocket transport has been removed: `mcp.client.websocket.websocket_client`, `mcp.server.websocket.websocket_server`, and the `ws` optional dependency extra (`mcp[ws]`) no longer exist. WebSocket was never part of the MCP specification. Use the streamable HTTP transport instead (`mcp.client.streamable_http.streamable_http_client` on the client, `streamable_http_app()` on the server), which supports bidirectional communication with server-to-client streaming over standard HTTP. + +## OAuth and server auth + +### OAuth metadata URLs no longer gain a trailing slash + +`OAuthMetadata`, `ProtectedResourceMetadata`, and `OAuthClientMetadata` now set +`url_preserve_empty_path=True` (Pydantic 2.12+). A path-less URL parsed from the wire keeps its +empty path instead of acquiring a trailing slash, so e.g. an `issuer` of `https://as.example.com` +round-trips as `https://as.example.com` rather than `https://as.example.com/`. This matters for +[RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207) / [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) issuer comparisons, which require simple string comparison ([RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986) §6.2.1). +URLs constructed in Python from an already-built `AnyHttpUrl` object are unaffected (they were +normalized at construction); only values parsed from strings/JSON change. + +This also changes the wire form of `OAuthClientMetadata.redirect_uris`: a path-less redirect URI +passed as a string (e.g. `redirect_uris=['http://localhost:8080']`) now serializes as +`http://localhost:8080` instead of `http://localhost:8080/`, and the client sends it verbatim in +the `/authorize` and token-exchange requests. [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) §3.1.2.3 requires authorization servers to +match redirect URIs by exact string comparison, so if you registered such a URI with a previous SDK +release (with the trailing slash) and the registration is persisted in `TokenStorage`, re-register +the client so the stored value matches what the SDK now transmits. + +`AuthSettings` now sets `url_preserve_empty_path=True` for the same reason: a path-less +`issuer_url` (or `resource_server_url`) passed as a string keeps its empty path, so the authorization +server advertises `issuer` as `https://as.example.com` rather than `https://as.example.com/` in its +metadata. Previously the trailing slash was added before the model saw the value, leaving the served +issuer inconsistent with what clients compare against under RFC 8414 / RFC 9207. Passing an +already-built `AnyHttpUrl` object still normalizes at construction; pass a string to get the +preserved form. + +### OAuth `callback_handler` returns `AuthorizationCodeResult` + +The `callback_handler` passed to `OAuthClientProvider` now returns an `AuthorizationCodeResult` instead of a `tuple[str, str | None]` of `(code, state)`. The new object adds an `iss` field so the client can validate the [RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207) authorization-response issuer ([SEP-2468](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468)): when the redirect carries an `iss` query parameter it must match the authorization server's issuer, and a missing `iss` is rejected when the server advertised `authorization_response_iss_parameter_supported`. + +**Before (v1):** + +```python +async def callback_handler() -> tuple[str, str | None]: + params = parse_qs(urlparse(await wait_for_redirect()).query) + return params["code"][0], params.get("state", [None])[0] +``` + +**After (v2):** + +```python +from mcp.client.auth import AuthorizationCodeResult + + +async def callback_handler() -> AuthorizationCodeResult: + params = parse_qs(urlparse(await wait_for_redirect()).query) + return AuthorizationCodeResult( + code=params["code"][0], + state=params.get("state", [None])[0], + iss=params.get("iss", [None])[0], + ) +``` + +Forward the `iss` query parameter from the redirect so the validation can run: omitting it makes the flow fail with `OAuthFlowError` against servers that advertise `authorization_response_iss_parameter_supported`, and silently skips the check for servers that send `iss` without advertising it. + +### Client rejects authorization server metadata with a mismatched `issuer` + +During OAuth discovery, `OAuthClientProvider` now validates that the authorization server +metadata's `issuer` exactly matches the authorization server URL advertised in the protected +resource metadata, as required by [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) +section 3.3 ([SEP-2468](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468)). +The comparison is a simple string comparison ([RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986) +section 6.2.1), so even a trailing-slash disagreement counts as a mismatch. v1 accepted the +metadata without checking, so a server pairing whose two values disagree authenticated fine +under v1 and now fails the entire flow. For example, when the MCP server's protected resource +metadata advertises + +```json +{"authorization_servers": ["https://as.example.com"]} +``` + +while the authorization server's RFC 8414 metadata says `"issuer": "https://as.example.com/"`, +v1 completes discovery and proceeds with the flow; v2 aborts with: + +```text +OAuthFlowError: Authorization server metadata issuer mismatch: https://as.example.com/ != https://as.example.com +``` + +There is no client-side override. Fix the deployment instead: make the authorization server's +`issuer` string-equal the URL in the protected resource metadata's `authorization_servers` +list. See [OAuth metadata URLs no longer gain a trailing slash](#oauth-metadata-urls-no-longer-gain-a-trailing-slash) +for how v2 preserves the exact string form of these URLs. + +### OAuth client requests `offline_access` and adds `prompt=consent` when the authorization server supports it ([SEP-2207](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207)) + +The OAuth client now augments its requested scope with `offline_access` whenever the +authorization server's metadata advertises that scope in `scopes_supported` and the client's +`grant_types` include `refresh_token`, which is the default. When `offline_access` ends up in +the requested scope, the authorization request also carries `prompt=consent`, as OIDC requires +for offline access. Against an authorization server that advertises `offline_access` (Keycloak +and Auth0 do by default), an unchanged v1 client sends a different authorization URL: + +**Before (v1):** + +```text +https://as.example.com/authorize?...&scope=read +``` + +**After (v2):** + +```text +https://as.example.com/authorize?...&scope=read offline_access&prompt=consent +``` + +Three observable consequences: end users see an interactive consent screen on every +authorization where OIDC providers previously re-authorized returning users silently, the +granted scope is broader with refresh tokens issued and persisted through `TokenStorage` where +v1 never requested them, and strict authorization servers that reject un-allowlisted scopes may +fail the flow with `invalid_scope`. The `prompt=consent` half applies even when +`offline_access` was already part of the scope selection in v1. + +To keep the v1 behavior (no `offline_access` request, no consent prompt, no refresh tokens), +restrict the client's grant types: + +```python +client_metadata = OAuthClientMetadata( + client_name="my-client", + redirect_uris=["http://localhost:3000/callback"], + grant_types=["authorization_code"], +) +``` + +Note this also registers the client without the `refresh_token` grant, so token refresh is +disabled; there is no knob for refresh tokens without the forced consent screen, since +`prompt=consent` is keyed off the final scope. + +### OAuth client credentials are bound to their authorization server ([SEP-2352](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2352)) + +Persisted OAuth client credentials are now bound to the authorization server that issued them: `OAuthClientInformationFull` records an `issuer`, set by the SDK after registration. When a server's protected resource metadata later points at a different authorization server, the client discards the bound credentials (and the old tokens) and re-registers with the new server instead of presenting one server's `client_id` to another. URL-based client IDs (CIMD) are portable and unaffected; credentials with no recorded issuer (pre-registered, or stored before this change) are left as-is. No API change for existing `TokenStorage` implementations - the `issuer` round-trips through the unchanged `get_client_info`/`set_client_info`. + +### Step-up authorization unions previously requested scopes ([SEP-2350](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350)) + +When a `403 insufficient_scope` challenge triggers step-up re-authorization, the OAuth client now requests the union of the previously requested scopes and the newly challenged scopes, instead of replacing the scope with only the challenged ones. This keeps permissions granted for earlier operations from being dropped when a later operation escalates. No API change; the wider scope is sent automatically on the re-authorization request. + +### OAuth Dynamic Client Registration sends `application_type` ([SEP-837](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837)) + +`OAuthClientMetadata` now carries an `application_type` field that is sent during Dynamic Client Registration. It defaults to `"native"`, which suits MCP clients that use loopback redirect URIs (CLI and desktop apps); browser-based clients served from a non-local host should set it to `"web"`: + +```python +from mcp.shared.auth import OAuthClientMetadata + +client_metadata = OAuthClientMetadata( + redirect_uris=["https://app.example.com/callback"], + application_type="web", +) +``` + +Under OIDC, omitting `application_type` defaults to `"web"`, which an authorization server may reject for the `localhost` redirect URIs native clients use; sending `"native"` avoids that. Non-OIDC servers ignore the parameter. + +### Stricter client authentication at `/token` and `/revoke` + +v2 hardens client authentication on SDK-hosted authorization servers (`create_auth_routes`) in two ways. Both apply automatically; server code only needs changing if you hand-provision client records. + +**Client-auth failures now return `invalid_client`.** In v1, every `ClientAuthenticator` failure at `/token` (unknown `client_id`, wrong secret, expired secret) returned HTTP 401 with `unauthorized_client`. v2 returns `invalid_client`, the code [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) §5.2 assigns to failed client authentication: + +```text +# v1 +401 {"error":"unauthorized_client","error_description":"Invalid client_id"} +# v2 +401 {"error":"invalid_client","error_description":"Invalid client_id"} +``` + +`unauthorized_client` is now reserved for a client that authenticated successfully but is not permitted the requested grant. Update any client code, integration tests, or alerting that string-matches the old error code, or accept both while clients and servers migrate at different times. + +**Secret-based clients without a stored secret are rejected.** In v1, `ClientAuthenticator` only validated a secret when one was stored, so a hand-provisioned client record with a secret-based auth method but no secret authenticated with no credentials at all. v2 rejects such clients before any grant processing: `/token` returns 401 `invalid_client` and `/revoke` returns 401 `unauthorized_client`, both with the description "Client is registered for secret-based authentication but has no stored secret". Only records that explicitly set `client_secret_post` or `client_secret_basic` with no secret are affected: records left at the default `token_endpoint_auth_method=None` fail in both versions, and DCR-registered clients always receive a generated secret. + +**Before (v1):** + +```python +from mcp.shared.auth import OAuthClientInformationFull + +LEGACY_CLIENT = OAuthClientInformationFull( + client_id="legacy-client", + client_secret=None, # no secret stored + token_endpoint_auth_method="client_secret_post", # but a secret-based method + redirect_uris=["http://localhost:1234/cb"], +) +``` + +**After (v2):** either register the client as public, or store a secret that clients must then present: + +```python +LEGACY_CLIENT = OAuthClientInformationFull( + client_id="legacy-client", + token_endpoint_auth_method="none", # public client, no secret expected + redirect_uris=["http://localhost:1234/cb"], +) +``` + +## Stricter protocol validation and wire behavior + +### Server handler results are validated against the protocol schema + +Results returned from server handlers are now validated against the negotiated protocol version's schema before being sent. A result that does not conform raises on the server side and the client receives an `INTERNAL_ERROR` response. The case most existing code will hit is `Tool.inputSchema`: the spec requires it to contain `"type": "object"`, so an empty `{}` is now rejected. + +### Client validates inbound traffic against the protocol schema + +`ClientSession` now validates server requests, notifications, and results against the negotiated protocol version's schema before parsing them into `mcp_types` models. Spec-invalid server output that the previous monolith parse tolerated may now raise `pydantic.ValidationError` from `list_tools()`, `call_tool()`, and similar calls. `_meta` remains the sanctioned place for result extras (and `experimental` for capability extras). + +### Unknown request methods now return `-32601` (Method not found) + +In v1, a request for a method the SDK didn't recognize failed request-union validation and was answered with `-32602` (`"Invalid request parameters"`, empty `data`). Any method the receiver doesn't serve — unrecognized on either side, or a spec method the server has no registered handler for — is now answered with the JSON-RPC-specified `-32601` (`"Method not found"`), with the method name in `data`, in every initialization state. Clients still decline sampling, elicitation, and roots requests with `-32600` when no callback is registered, as in v1. Update anything that matched on the old code for this case. + +### Every outbound request now carries a `_meta` envelope; OpenTelemetry is on by default + +v2 sends `"_meta": {}` in the params of every request it emits, at every negotiated protocol version. Requests that had no params in v1, such as `ping` and `tools/list`, now carry `"params": {"_meta": {}}`; server-initiated requests get the same envelope. This is spec-valid and accepted by all peers, but wire traffic differs from v1 on every call, and no configuration restores the v1 wire shape. Update any test or tooling that asserts on raw outbound request bytes. + +**Before (v1):** same client code, 2025-11-25 peer: + +```text +{"method":"ping","jsonrpc":"2.0","id":1} +{"method":"tools/list","jsonrpc":"2.0","id":2} +``` + +**After (v2):** + +```text +{"jsonrpc":"2.0","id":2,"method":"ping","params":{"_meta":{}}} +{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{"_meta":{}}} +``` + +The envelope exists for OpenTelemetry trace propagation ([SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414)), which now ships enabled: every server installs a tracing middleware and the client opens a span per outbound request. With no OpenTelemetry SDK configured these are no-ops and only the empty envelope is visible. If your application already configures a global tracer provider, it starts recording MCP client and server spans with no code change, and a W3C `traceparent` field is injected into outbound `_meta`, propagating your trace ids to the servers you call. To suppress the spans, filter the `mcp-python-sdk` tracer in your pipeline; [OpenTelemetry](run/opentelemetry.md) has the recipe for removing the server middleware. There is no public switch for the client-side span and `traceparent` injection. + +The SDK's new `opentelemetry-api` runtime dependency is covered under [Packaging, dependencies, and CLI](#packaging-dependencies-and-cli). + +## Testing utilities + +### `create_connected_server_and_client_session` removed + +The `create_connected_server_and_client_session` helper in `mcp.shared.memory` has been removed. Use `mcp.client.Client` instead — it accepts a `Server` or `MCPServer` instance directly and handles the in-memory transport and session setup for you. + +**Before (v1):** + +```python +from mcp.shared.memory import create_connected_server_and_client_session + +async with create_connected_server_and_client_session(server) as session: + result = await session.call_tool("my_tool", {"x": 1}) +``` + +**After (v2):** + +```python +from mcp.client import Client + +async with Client(server) as client: + result = await client.call_tool("my_tool", {"x": 1}) +``` + +`Client` accepts the same callback parameters the old helper did (`sampling_callback`, `list_roots_callback`, `logging_callback`, `message_handler`, `elicitation_callback`, `client_info`), keeps `raise_exceptions` for surfacing server-side errors and `read_timeout_seconds` (now a plain `float` of seconds rather than a `timedelta`; see [Timeouts take `float` seconds instead of `timedelta`](#timeouts-take-float-seconds-instead-of-timedelta)), and adds `mode` to control version negotiation (`'auto'` by default; `'legacy'` reproduces v1's initialize-only handshake). + +If you need direct access to the underlying `ClientSession` and memory streams (e.g., for low-level transport testing), `create_client_server_memory_streams` is still available in `mcp.shared.memory`: + +```python +import anyio +from mcp.client.session import ClientSession +from mcp.shared.memory import create_client_server_memory_streams + +async with create_client_server_memory_streams() as (client_streams, server_streams): + async with anyio.create_task_group() as tg: + tg.start_soon(lambda: server.run(*server_streams, server.create_initialization_options())) + async with ClientSession(*client_streams) as session: + await session.initialize() + ... + tg.cancel_scope.cancel() +``` + +Note that the streams it yields are now context-propagating wrappers (`ContextReceiveStream`/`ContextSendStream`) rather than plain anyio memory streams. They support `send`, `receive`, async iteration, `close`, `aclose`, and `clone`, but the anyio-only methods `send_nowait`, `receive_nowait`, and `statistics()` are gone and raise `AttributeError`; use `await send(...)`/`await receive()` instead, or create plain `anyio.create_memory_object_stream` pairs yourself if you need the full anyio API. + +One behavioral caveat when moving progress-reporting handlers onto `Client(server)`: reading `ctx.meta["progress_token"]` and calling `session.send_progress_notification(token, ...)` is specific to the JSON-RPC transport path. On the in-process modern path (`DirectDispatcher` / `Client(server)`), there is no wire token in `_meta`, so handlers that gate progress on the token's presence go silent. + +`ctx.report_progress(progress, total, message)` works on every dispatcher: it sends a progress notification when a token is present and routes the update through the dispatcher's progress channel otherwise, no-opping only when the caller did not request progress at all (see also [Client-to-server progress deprecated](#client-to-server-progress-deprecated-2026-07-28)). `session.send_progress_notification(progress_token, ...)` is unchanged and still works on JSON-RPC transports for code that already holds a token. + +## Deprecations + +### Client resource-subscription methods deprecated (SEP-2575) + +[SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2575) removes `resources/subscribe` and `resources/unsubscribe` from the 2026-07-28 wire; per-URI subscriptions travel in the `subscriptions/listen` filter instead. The client verbs now carry `typing_extensions.deprecated`: + +- `Client.subscribe_resource()` / `Client.unsubscribe_resource()` +- `ClientSession.subscribe_resource()` / `ClientSession.unsubscribe_resource()` + +They keep working against 2025-era servers; a 2026-07-28 server answers them with `-32601` (method not found). Migrate to the listen driver: + +```python +async with client.listen(resource_subscriptions=["board://sprint"]) as sub: + async for event in sub: # ResourceUpdated(uri="board://sprint") + ... +``` + +See the [Subscriptions](client/subscriptions.md#watching-the-stream) page under Clients for the full client-side contract (typed events, the honored filter, clean end vs `SubscriptionLost`). + +### Roots, Sampling, and Logging methods deprecated (SEP-2577) + +[SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) deprecates the Roots, Sampling, and Logging features as of the 2026-07-28 spec. The deprecation is advisory only: there are no wire-level changes, capability negotiation is unchanged, and every method keeps working for sessions negotiating 2025-11-25 and earlier. + +The user-facing methods for these features now carry `typing_extensions.deprecated`, so type checkers, IDEs, and the runtime surface a deprecation warning where they are called: + +- Sampling: `ServerSession.create_message()`, `ClientPeer.sample()` +- Roots: `ServerSession.list_roots()`, `ClientPeer.list_roots()`, `ClientSession.send_roots_list_changed()`, `Client.send_roots_list_changed()` +- Logging: `ServerSession.send_log_message()`, `Connection.log()`, `ClientSession.set_logging_level()`, `Client.set_logging_level()`, `mcp.server.context.Context.log()` (the lowlevel `Context`), and the `MCPServer` `Context` helpers `log()`, `debug()`, `info()`, `warning()`, `error()` + +Registering a handler for a deprecated capability is deprecated too. The `Server.__init__` parameters `on_set_logging_level` (Logging) and `on_roots_list_changed` (Roots) are now split out into a `typing_extensions.deprecated` overload, so passing either is flagged by type checkers and emits `mcp.MCPDeprecationWarning` at construction time. `on_progress` follows the same pattern (see below). The non-deprecated overload omits these parameters, so the common case stays warning-free. + +The runtime warning is emitted as `mcp.MCPDeprecationWarning`, which subclasses `UserWarning` (not `DeprecationWarning`) so it is visible by default. To silence it, filter that category: + +```python +import warnings +from mcp import MCPDeprecationWarning + +warnings.filterwarnings("ignore", category=MCPDeprecationWarning) +``` + +No migration is required during the deprecation window. New code should avoid building on these features, since they may be removed in a future spec version. + +### Client-to-server progress deprecated (2026-07-28) + +The 2026-07-28 spec restricts `notifications/progress` to the server-to-client direction only — `ProgressNotification` is no longer in the spec's `ClientNotification`. `Client.send_progress_notification()` and `ClientSession.send_progress_notification()` now carry `typing_extensions.deprecated` and emit `mcp.MCPDeprecationWarning` at runtime. They continue to work against servers negotiating 2025-11-25 or earlier. Registering a lowlevel `Server` `on_progress` handler is deprecated the same way as the SEP-2577 handler parameters above: it sits in the `typing_extensions.deprecated` `Server.__init__` overload and passing it emits `mcp.MCPDeprecationWarning` at construction time. + +On the server side, prefer the new dispatcher-agnostic `ServerSession.report_progress(progress, total, message)` (and `Context.report_progress()` on `MCPServer`) over the raw `ServerSession.send_progress_notification(progress_token, …)`. `report_progress` encapsulates the "no-op when the caller did not request progress" rule and works on every dispatcher; the raw token-taking form remains for handlers that read `_meta.progressToken` directly. + +## Notes for 2026-era connections + +Everything below this heading describes behavior that only activates on connections +negotiated at protocol 2026-07-28 or later. Migrated v1 code talking to 2025-11-25 (or +earlier) peers is unaffected. It is collected here so the rest of this guide stays +focused on the v1-to-v2 upgrade itself. + +### Servers validate `Mcp-Param-*` headers against the request body ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)) + +On the 2026-07-28 Streamable HTTP path, a `tools/call` whose tool declares `x-mcp-header` annotations is validated before dispatch — each annotated argument and its mirroring `Mcp-Param-*` header must be present together and agree (after base64-sentinel decoding; integers compare numerically), or absent together. A violation is rejected with HTTP 400 and JSON-RPC error `-32020` (`HeaderMismatch`), as the spec requires. A client that sends an annotated argument *without* its header — for example one that never listed the tool — is therefore rejected instead of silently served; the spec's recovery is to re-list and retry. On the client side, `ClientSession.call_tool` emits these headers automatically for annotated arguments of any tool it has listed; list the tool first, and note that pre-2026 connections and non-HTTP transports never emit them. + +There is nothing to configure. The server resolves the called tool's schema through its own registered `tools/list` handler (for `MCPServer`, the built-in one), so the validated catalog is exactly what that caller would be shown. Two consequences worth knowing: the listing runs internally on validated calls, so middleware and an expensive or paginated `tools/list` handler see extra invocations; and validation is skipped — never failing the call — when no `tools/list` handler is registered, the tool isn't in the listing, the handler raises (logged as an error), or the call has no arguments and no `Mcp-Param-*` headers. Headers with no matching annotation are ignored; a recognized header supplied more than once is rejected, as is a duplicated `MCP-Protocol-Version`, `Mcp-Method`, or `Mcp-Name` line. The codec and validator are public in `mcp.shared.inbound` (`decode_header_value`, `validate_mcp_param_headers`) for low-level servers hosting their own HTTP entry. + +Base64-sentinel decoding is strict everywhere it applies, including the `Mcp-Name` header: a `=?base64?...?=` value whose payload is not canonical base64 (wrong padding, stray characters, non-zero trailing bits) or not valid UTF-8 is rejected as malformed rather than leniently decoded. + +## Need Help? + +If you encounter issues during migration: + +1. Check the [API Reference](api/mcp/index.md) for updated method signatures +2. Review the [examples](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples) for updated usage patterns +3. Open an issue on [GitHub](https://github.com/modelcontextprotocol/python-sdk/issues) if you find a bug or need further assistance diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md new file mode 100644 index 0000000..221a87d --- /dev/null +++ b/docs/protocol-versions.md @@ -0,0 +1,127 @@ +# Protocol versions + +MCP has two eras. + +Servers released before 2026-07-28 open every connection with the **`initialize` handshake**: the client proposes a version, the server counters, the client acknowledges, all before the first useful request. Servers at **2026-07-28** drop the handshake. The client sends one **`server/discover`** probe and the server answers it with everything in a single result. + +You almost never have to care, because `Client` negotiates for you. This page is about the one constructor argument that controls it, `mode=`, and the three times you change it. + +## `mode="auto"` + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/protocol_versions/tutorial001.py" +``` + +You didn't pass `mode`, so you got the default: `"auto"`. Entering `async with` sends a single `server/discover` probe at the newest version this SDK speaks. Then: + +* A **modern server** answers it. The client adopts the result. One round trip, done. +* An **older server** has never heard of `server/discover` and returns an error. The client falls back to the classic `initialize` handshake and takes whatever that negotiates. + +Either way you come out connected, and `client.protocol_version` tells you which it was: + +```text +2026-07-28 +``` + +That is the whole feature. One `Client`, any era of server, no branching in your code. + +!!! info + `MCPServer` answers `server/discover` on every transport — in-memory, stdio, streamable + HTTP — so against your own server `auto` always lands on `2026-07-28`. The fallback only + ever fires against a real pre-2026 server, which is exactly when you want it to. + +## `mode="legacy"` + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial002.py" +``` + +`mode="legacy"` never probes. It runs the `initialize` handshake, the same connection a pre-2026 client opens. + +```text +2025-11-25 +``` + +Same server. It speaks `2026-07-28` perfectly well; you told the client not to ask. + +You want this for the **push-style** features. + +A server-initiated request is the server calling *you*: `ctx.elicit(...)` putting a form in front of your user, sampling asking your model for a completion mid-tool-call. That channel only exists on a handshake-era session. + +At 2026-07-28 it is gone. The server *returns* its questions and you retry the call with the answers (**[Multi-round-trip requests](handlers/multi-round-trip.md)**). + +`mode="auto"` only gives you a handshake when the server is too old for anything else. `mode="legacy"` guarantees one. Reach for it whenever you hand `Client(...)` a `sampling_callback`, an `elicitation_callback` you want driven as a request, or a `message_handler`. **[Client callbacks](client/callbacks.md)** goes through each. + +## Pinning a version + +`mode` also accepts a modern protocol version string. Today that set is exactly `["2026-07-28"]`. + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial003.py" +``` + +A pin sends **nothing**. No probe, no handshake. The client adopts `2026-07-28` locally and the connection is live the instant `async with` returns. + +A pin is a promise *you* make: you already know the server speaks that version. The client doesn't check. + +!!! check + A pin is not a discovery. Print `client.server_info` and the price is right there: + + ```text + name='' title=None version='' description=None website_url=None icons=None + ``` + + The client never asked the server who it is, so `server_info` is a blank. `client.server_capabilities` + is the same story: every capability is `None`. Tool calls still work (the protocol needs none of it); + code that reads `server_capabilities` to decide what to offer does not. + + The next section is the fix. + +Only modern versions are pinnable. A handshake-era string is rejected at construction, before any I/O, and the error tells you what to write instead: + +```text +ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-06-18' ('2025-06-18' is a handshake-era version; use mode='legacy') +``` + +## Reconnecting with `prior_discover` + +The probe is cheap, but it is still a round trip you pay on every reconnect, and the answer almost never changes. + +So keep it. After an `auto` connection, `client.session.discover_result` holds the exact `DiscoverResult` the server sent: its `supported_versions`, its `capabilities`, its `server_info`, its `instructions`. Hand it back as `prior_discover=` the next time: + +```python title="client.py" hl_lines="15 17" +--8<-- "docs_src/protocol_versions/tutorial004.py" +``` + +```text +2026-07-28 +Bookshop +``` + +The second connection made **zero** negotiation round trips and still knows exactly who it is talking to. That is the pinned mode done properly: `mode=` names the version, `prior_discover=` supplies the identity. ✨ + +`DiscoverResult` is a Pydantic model. `saved.model_dump_json()` goes into a file or a cache; `DiscoverResult.model_validate_json(...)` brings it back in the next process. + +!!! tip + `prior_discover=` only does anything when `mode` is a version pin. Under `"auto"` the client + probes the server anyway, and under `"legacy"` it is ignored. + +## The four modes + +| You write | Negotiation traffic | You get | +| --- | --- | --- | +| `Client(target)` | one `server/discover` probe; the `initialize` handshake if it fails | the newest version both sides speak, whichever era | +| `Client(target, mode="legacy")` | the `initialize` handshake | a handshake-era version; server-initiated requests work | +| `Client(target, mode="2026-07-28")` | none | that version, pinned, with a blank `server_info` | +| `Client(target, mode="2026-07-28", prior_discover=saved)` | none | that version, pinned, *and* the identity you saved last time | + +## Recap + +* MCP has a handshake era (up to `2025-11-25`, the `initialize` handshake) and a modern era (`2026-07-28`, `server/discover`). `Client` bridges them. +* `mode="auto"` is the default: probe, fall back. Leave it alone unless one of the other three rows describes you. +* `client.protocol_version` is always the answer to "what did I get?". +* `mode="legacy"` forces the handshake. It is what you need for server-initiated requests: sampling, push elicitation, `message_handler`. +* A version pin (`mode="2026-07-28"`) sends no negotiation traffic at all, at the cost of a blank `server_info`. +* `prior_discover=` pays that cost back: save `client.session.discover_result`, reconnect with it, get both. + +A modern connection has no push channel, so how does a 2026 server ask you a question mid-call? It returns it: **[Multi-round-trip requests](handlers/multi-round-trip.md)**. diff --git a/docs/run/asgi.md b/docs/run/asgi.md new file mode 100644 index 0000000..2eca927 --- /dev/null +++ b/docs/run/asgi.md @@ -0,0 +1,140 @@ +# Add to an existing app + +`mcp.run("streamable-http")` starts a web server for you. Sometimes you don't want that: your MCP server is one piece of a larger web application, or you already have an ASGI deployment. + +For that, `mcp.streamable_http_app()` returns a **Starlette application**. + +A Starlette app is an ASGI app, so anything that hosts ASGI (uvicorn, Hypercorn, another Starlette, FastAPI) can host your MCP server. + +## The app + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/asgi/tutorial001.py" +``` + +`app` is an ordinary ASGI application. Hand it to any ASGI server: + +```console +uvicorn server:app +``` + +The MCP endpoint is at `/mcp`, so a client connects to `http://127.0.0.1:8000/mcp`. + +The app already carries two things: + +* One route, `/mcp`: the Streamable HTTP endpoint. +* A **lifespan** that starts `mcp.session_manager`, the object that owns every live session's background work. + +Run the app on its own (`uvicorn server:app`) and you never think about either. + +!!! tip + `streamable_http_app()` takes the same keyword arguments as `mcp.run("streamable-http", ...)`, + minus `port`: the port belongs to whatever serves the app. `host` is still accepted but binds + nothing here; **[Deploy & scale](deploy.md)** explains what it actually controls. + **[Running your server](index.md)** covers the options themselves. + +`mcp.sse_app()` does the same for the superseded SSE transport. + +## Localhost only, until you say otherwise + +Out of the box the app answers **only** requests addressed to localhost. `streamable_http_app()` +cannot know which hostname it will be served behind, so it arms DNS-rebinding protection with the +safest possible allowlist; on your machine that is exactly right. Deployed behind a real hostname, +it means **every request is rejected with `421 Misdirected Request`** until you pass +`transport_security=` an allowlist of what you actually serve. Nothing you built is even +consulted first. That allowlist, and everything else between a working app and a real hostname, +is **[Deploy & scale](deploy.md)**. + +## Mounting it + +The moment the MCP server is *part* of a bigger application, you put the app inside a `Mount`. And the moment you do that, the lifespan becomes your problem: + +```python title="server.py" hl_lines="18-21 25-26" +--8<-- "docs_src/asgi/tutorial002.py" +``` + +* `Mount("/", ...)` plus the default `/mcp` path keeps the endpoint at `/mcp`. Starlette tries routes in order and `Mount("/")` matches **every** path, so your own routes go *before* it in the list. Anything after it is unreachable. +* The `lifespan` function enters `mcp.session_manager.run()` for the lifetime of the **host** app. This is the line everyone forgets. +* `mcp.session_manager` only exists *after* `streamable_http_app()` has been called. That is why the routes are built at module level and the manager is only touched inside the lifespan. + +Starlette's `Host` route works the same way: swap `Mount("/", ...)` for `Host("mcp.example.com", ...)` to route by hostname instead of by path. The lifespan rule does not change, and neither does the transport-security one. A `Host("mcp.example.com", ...)` route only ever receives requests addressed to that hostname, but the transport's own Host allowlist (**[Deploy & scale](deploy.md)**) still runs first. Without `"mcp.example.com"` in it, that route answers every one of them with a `421`. + +!!! warning "The host app owns the lifespan" + `streamable_http_app()` wires `session_manager.run()` into the lifespan of the Starlette it + returns, but **a mounted sub-application's lifespan never runs**. Mount the app and that + built-in lifespan is dead code. Whichever app sits at the top of your ASGI stack must enter + `mcp.session_manager.run()` in its own lifespan. + +!!! check + Delete the `lifespan=lifespan` line and start the server. It starts. The route resolves. + Then the first request to `/mcp` fails with: + + ```text + RuntimeError: Task group is not initialized. Make sure to use run(). + ``` + + Nothing starts the session manager except its `run()`. + +## Two servers, one app + +Each `MCPServer` is its own app with its own session manager. Mount as many as you like; enter every manager from the one host lifespan: + +```python title="server.py" hl_lines="27-30 35-36" +--8<-- "docs_src/asgi/tutorial003.py" +``` + +* `AsyncExitStack` enters both managers; they start together and shut down in reverse order. +* The endpoints are `/notes/mcp` and `/tasks/mcp`: the mount prefix plus the default path. + +## Changing the path + +That trailing `/mcp` is `streamable_http_path`. Set it to `"/"` and the mount prefix becomes the whole public path: + +```python title="server.py" hl_lines="25" +--8<-- "docs_src/asgi/tutorial004.py" +``` + +Now clients connect to `/notes`, not `/notes/mcp`. + +## CORS for browser clients + +A browser-based client needs two permissions from you: to **send** its MCP request headers, and to **read** the one MCP sends back. Both are CORS configuration on the host app, and the transport-security allowlist above has to agree with it: + +```python title="server.py" hl_lines="27-30 33 35-49" +--8<-- "docs_src/asgi/tutorial005.py" +``` + +* `allow_headers` is the half everyone forgets. A browser **preflights** every MCP request, because `Content-Type: application/json` and the `Mcp-*` request headers are not on the CORS safelist, and a header the preflight doesn't grant is a request the browser never sends. (`allow_headers=["*"]` also works: Starlette answers a preflight with whatever it asked for.) +* `expose_headers=["Mcp-Session-Id"]` is the read half. Streamable HTTP returns the session ID in that response header, and browsers hide response headers from JavaScript unless CORS exposes them by name. Without it the client can never make its second request. +* `allow_origins` is your decision, not MCP's. Be precise, and mirror it in `allowed_origins=` above: the browser enforces CORS, but the server checks `Origin` itself, and an origin the transport doesn't trust gets a `403` even after a clean preflight. +* `allow_methods` lists the three methods Streamable HTTP uses: `POST` to send messages, `GET` to open the server-to-client stream, `DELETE` to end the session. + +## Custom routes + +`@mcp.custom_route()` registers a plain HTTP endpoint on the same app, for the things every deployed service needs that have nothing to do with MCP: a health check, an OAuth callback. + +```python title="server.py" hl_lines="15-17" +--8<-- "docs_src/asgi/tutorial006.py" +``` + +* The handler is plain Starlette: an `async` function from `Request` to `Response`. +* `streamable_http_app()` picks up every custom route. `app.routes` is now `/mcp` and `/health`. +* `GET /health` answers `{"status": "ok"}` with no MCP in sight. + +!!! warning + Custom routes are **never authenticated**, even when the rest of the server is. That is + deliberate: health checks and OAuth callbacks have to be reachable before any token exists. + Don't put anything private behind one. + +## Recap + +* `mcp.streamable_http_app()` returns a Starlette app with one route, `/mcp`. Any ASGI server can run it. +* Out of the box the app answers only requests addressed to localhost, and behind a real hostname it rejects everything with a `421` until you pass `transport_security=` an allowlist. **[Deploy & scale](deploy.md)** owns that, and the rest of the road to production. +* `Mount` (or `Host`) puts it inside a bigger Starlette or FastAPI app. +* **Mounting disables the built-in lifespan.** The host app's lifespan must enter `mcp.session_manager.run()`, or the first request fails. +* Several servers in one app means several mounts and one lifespan that enters every session manager. +* `streamable_http_path="/"` moves the endpoint to the mount prefix itself. +* Browser clients need CORS: `allow_headers` for the `Mcp-*` request headers, `expose_headers=["Mcp-Session-Id"]` for the response. +* `@mcp.custom_route()` adds plain, unauthenticated HTTP endpoints next to `/mcp`. + +Once the server is reachable at a real URL, **[The Client](../client/index.md)** connects to it with that URL instead of a server object. diff --git a/docs/run/authorization.md b/docs/run/authorization.md new file mode 100644 index 0000000..b7d731b --- /dev/null +++ b/docs/run/authorization.md @@ -0,0 +1,125 @@ +# Authorization + +Over Streamable HTTP your MCP server is an ordinary web service, and you protect it the way you protect any web service: with OAuth 2.1 bearer tokens. + +In OAuth terms, your server is a **resource server**. It never signs anyone in and it never issues a token. It does one thing: look at the `Authorization` header on each request and decide whether the token in it is good. + +This page is the server side. A client that discovers your authorization server and fetches the token is **[OAuth clients](../client/oauth-clients.md)**. + +## The three parties + +* The **authorization server** signs people in and issues access tokens. You don't write this. It's your identity provider (Auth0, Keycloak, Entra, your own). +* The **resource server** is your MCP server. It verifies the token on every request. +* The **client** discovers which authorization server you trust, gets a token from it, and sends it back to you as `Authorization: Bearer <token>`. + +That's the whole triangle. Everything on this page is the middle bullet. + +## A token verifier + +The SDK has no opinion about what a valid token looks like. You tell it, by implementing **`TokenVerifier`**: + +```python title="server.py" hl_lines="12-14 19-24" +--8<-- "docs_src/authorization/tutorial001.py" +``` + +* `TokenVerifier` is a protocol with one async method. `verify_token` gets the raw token from the `Authorization` header and returns an **`AccessToken`** if it's valid, `None` if it isn't. There is nothing else to implement. +* This one looks the token up in a table. A real one verifies a JWT signature or calls the authorization server's token-introspection endpoint. That code is yours; the SDK only calls it. +* `token_verifier=` and `auth=` always travel together. Pass one without the other and `MCPServer(...)` raises a `ValueError` before it ever serves a request. + +`AuthSettings` is the public face of your resource server: + +* `issuer_url`: the authorization server that issues your tokens. +* `resource_server_url`: the public URL of this MCP endpoint. It names *which* resource a token is for, and it's where the discovery document lives. +* `required_scopes`: every token must carry all of them. + +!!! tip + `examples/servers/simple-auth/` in the SDK repository has an `IntrospectionTokenVerifier` that calls + a real authorization server's [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) endpoint. It's the shape most production verifiers take. + +## What you get over HTTP + +Authorization lives in HTTP headers, so it exists only on the HTTP transports. Run it on the one you deploy: `mcp.run(transport="streamable-http")` puts it on `http://127.0.0.1:8000/mcp`, and **[Running your server](index.md)** has the rest. The app now has two routes: + +```text +/mcp +/.well-known/oauth-protected-resource/mcp +``` + +You registered one tool. The second route is the SDK's. + +### Discovery + +`GET` that well-known path and you get **[RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata**, built straight from your `AuthSettings`: + +```json +{ + "resource": "http://127.0.0.1:8000/mcp", + "authorization_servers": ["https://auth.example.com/"], + "scopes_supported": ["notes:read"], + "bearer_methods_supported": ["header"] +} +``` + +This document is how a client that has never heard of your server finds its way in: it reads `authorization_servers` and goes there for a token. You wrote none of it. + +!!! check + Call `/mcp` with no token (or with one your verifier returned `None` for) and the request is + stopped at the door: + + ```text + HTTP/1.1 401 Unauthorized + WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp" + + {"error": "invalid_token", "error_description": "Authentication required"} + ``` + + Nothing was parsed and no tool ran. And that `resource_metadata` pointer in `WWW-Authenticate` is + what makes discovery automatic: 401 -> metadata document -> authorization server -> token -> retry. + +!!! warning + None of this protects `stdio`. A pipe has no `Authorization` header, so `token_verifier` is never + consulted there. A `stdio` server's security boundary is the process that launched it. The same + goes for the in-memory `Client(mcp)` you use in tests: it connects straight to the server object + and skips the HTTP layer, authorization included. + +## The caller's identity + +Inside any handler, **`get_access_token()`** is the `AccessToken` your verifier returned for the current request: + +```python title="server.py" hl_lines="4 32-35" +--8<-- "docs_src/authorization/tutorial002.py" +``` + +* It works in tools, resources, and prompts, and there is nothing to pass around: the auth middleware stores it in a context variable per request. +* You get back the **same object your verifier built**: `client_id`, `scopes`, `subject`, `expires_at`, and any extra `claims` you attached. That's the hook for per-tool rules: read the scopes and refuse. +* Outside an authenticated HTTP request it returns `None`. In-memory and over `stdio` it is always `None`. + +Call `whoami` with `Authorization: Bearer alice-token` and the model reads: + +```text +alice (scopes: notes:read) +``` + +## The half the SDK doesn't do + +The SDK gives you the resource-server half: verify, advertise, refuse. It does not give you a login page, a consent screen, or a token. + +To watch all three parties move, run `examples/servers/simple-auth/` from the SDK repository (a small authorization server and a resource server set up exactly like this page) and then point `examples/clients/simple-auth-client/` at it for the full discovery-and-token dance. + +!!! info + There is a second constructor argument, `auth_server_provider=`, that embeds a full authorization + server inside your MCP server. It predates the AS/RS separation that the MCP authorization spec + is built around. New servers should not reach for it. + +An authorization server can also accept an enterprise identity provider's signed assertion in place of a user clicking through a consent screen, and the SDK supports both sides of that exchange. The grant, and the client that presents it, is **[Identity assertion](../client/identity-assertion.md)**. + +## Recap + +* Over Streamable HTTP your server is an OAuth 2.1 **resource server**: it verifies tokens, it never issues them. +* `TokenVerifier` is the whole integration surface: one async method, token in, `AccessToken | None` out. +* `token_verifier=` and `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` always travel together. +* The SDK publishes [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata at `/.well-known/oauth-protected-resource/...` and answers unauthenticated requests with a 401 whose `WWW-Authenticate` header points at it. That is the entire discovery story. +* `get_access_token()` in any handler is who's calling. +* Authorization is an HTTP concern. `stdio` and the in-memory client never see it. + +The client half (discovering your authorization server and fetching the token for you) is **[OAuth clients](../client/oauth-clients.md)**. And a client that *asserts* an identity instead of asking a user for one is **[Identity assertion](../client/identity-assertion.md)**. diff --git a/docs/run/deploy.md b/docs/run/deploy.md new file mode 100644 index 0000000..7cec581 --- /dev/null +++ b/docs/run/deploy.md @@ -0,0 +1,174 @@ +# Deploy & scale + +Your server works. Now it needs a real hostname, and more than one worker behind it. + +Almost none of that is MCP's business. You bring the ASGI server, the process manager, the load balancer. What this page has is the short list of things that *are* MCP's business: one setting that gates every deployment, and the two places where "more than one worker" changes what the SDK does. + +## Before anything else: the Host allowlist + +`streamable_http_app()` cannot know which hostname it will be served behind, so it assumes the safest answer: localhost. With no `transport_security=`, the app switches on **DNS-rebinding protection** and accepts a request only if its `Host` header is `127.0.0.1:<port>`, `localhost:<port>`, or `[::1]:<port>`. The `Origin` header, when there is one, has to be the `http://` form of the same. On your machine that is exactly right: it stops a malicious web page from driving your local server through a DNS name it rebound to `127.0.0.1`. + +Deployed behind a real hostname, that same default rejects **every request** until you say otherwise. The check runs before anything MCP-shaped does, so nothing you built is even consulted: + +```text +421 Misdirected Request Invalid Host header the Host is not in the allowlist +403 Forbidden Invalid Origin header the Origin is not in the allowlist +``` + +`transport_security=` is the fix. Allowlist what you actually serve: + +```python title="server.py" hl_lines="2 13-17" +--8<-- "docs_src/deploy/tutorial001.py" +``` + +* `allowed_hosts` entries are exact strings: `"mcp.example.com"` matches a bare `Host` header and `"mcp.example.com:*"` matches any port. List both. +* `allowed_origins` only matters for browsers, because nothing else sends `Origin`. It is the server-side twin of the CORS configuration in **[Add to an existing app](asgi.md)**. +* Behind a reverse proxy that already controls the `Host` header, switching the check off is the honest configuration: `TransportSecuritySettings(enable_dns_rebinding_protection=False)`. +* Passing a non-localhost `host=` (for example `host="mcp.example.com"`) does **not** allowlist that hostname. It only stops the localhost default from arming the protection, which leaves every Host and Origin accepted. Say what you mean with `transport_security=` instead. + +!!! check + Delete the `transport_security=security` argument and deploy the app anyway. It starts, `/mcp` + routes, and every request (including from a plain `curl`) comes back: + + ```text + HTTP/1.1 421 Misdirected Request + + Invalid Host header + ``` + + You will not find those words on the client side. A `421` is a plain-text HTTP response, not a + JSON-RPC error, so the MCP client raises a generic transport error; the hostname it + didn't like appears only in the **server's** log, as a single warning. A freshly + deployed server that refuses every connection is a Host allowlist until proven otherwise. + **[Troubleshooting](../troubleshooting.md)** starts here too. + +## Workers, and who has to be sticky + +Once the hostname answers, put more than one worker behind it. There is no SDK knob for that; you scale a Starlette app the way you scale any ASGI app, by handing the object to something that knows how to fork: + +```console +uvicorn server:app --workers 4 +``` + +Four processes, one socket. And now the question every deployment has to answer: **does a request have to reach the worker that saw the last one?** + +For a client speaking the **2026-07-28** protocol, no. A modern request is one self-contained POST: no `initialize` handshake before it, no `Mcp-Session-Id` on the response, nothing for a second request to come back *to*. Route it to any worker. + +That is not a mode you switch on. `stateless_http=True` looks like it should be, but the transport routes on the `MCP-Protocol-Version` request header, hands a modern request to the modern handler, and **returns**. The line that reads `stateless_http` comes *after* that return. It isn't that the flag is ignored on the 2026-07-28 path; it is never reached. `stateless_http` is a knob for the **legacy** leg only, and the modern path is sessionless by construction. + +For a legacy client on spec version 2025-11-25 or earlier, the answer depends on that flag: + +| Client's protocol version | Session | What the load balancer must do | +| --- | --- | --- | +| **2026-07-28** | None. `Mcp-Session-Id` is never set. | Nothing. Any worker serves any request. | +| **2025-11-25 and earlier** (the default) | `Mcp-Session-Id`, held in one worker's memory. | **Sticky sessions.** A follow-up that reaches a different worker gets a `404` *"Session not found"*. | +| **2025-11-25 and earlier**, with `stateless_http=True` | None. | Nothing. The cost is the server-to-client back-channel (sampling, push elicitation, `roots/list`) and resumability. | + +Sticky sessions and what the legacy leg costs are their own page, **[Serving legacy clients](legacy-clients.md)**; the two eras themselves are **[Protocol versions](../protocol-versions.md)**. What matters here is the shape of the answer: *on 2026-07-28 you are already stateless, with nothing to configure.* + +The rest of this page is the two things that being stateless does **not** buy you. + +## `requestState` across workers + +A **[multi-round-trip](../handlers/multi-round-trip.md)** tool needs something the client has to go get (a confirmation, a choice, a credential), so it returns a question instead of an answer and finishes on the retry. Between the two rounds the client holds an opaque `request_state` token the server minted. On the retry the server has to open that token again. + +*Sealed under what key?* By default, one the server generated with `os.urandom(32)` at construction time. Under `--workers 4` that is four constructions, in four processes: four different keys, never written anywhere, never shared, gone on restart. + +Here is a tool that asks before it acts, on a server that configures nothing: + +```python title="server.py" hl_lines="15 21" +--8<-- "docs_src/deploy/tutorial002.py" +``` + +The first round reaches worker A. Worker A seals `refund:120` under **its** key and returns the token. The client puts the question in front of a person, gets a yes, and retries. The retry is a brand-new HTTP request. + +!!! check + Let that retry reach worker B. B tries to unseal a token it did not mint, cannot, and refuses the + whole round. `refund` is never called; the client gets a JSON-RPC error: + + ```json + { + "code": -32602, + "message": "Invalid or expired requestState", + "data": {"reason": "invalid_request_state"} + } + ``` + + That message is **frozen**. Expired, tampered with, replayed against different arguments, or (by + far the most common cause in a real deployment) sealed by a sibling worker: the client is told + the same thing every time, so the wire never reveals which check failed. The real reason is one + `WARNING` in the server's log: + + ```text + requestState rejected on tools/call: unknown key + ``` + + A multi-round-trip tool that worked with one worker and started failing *some of the time* at + two is this. Both rounds still have to reach the same process, so it fails exactly as often as + your load balancer separates them. + +The two rounds are two independent HTTP requests, and several ordinary things separate them: a proxy that balances per request, a connection that dropped in between, a deploy or a restart, a client that persisted `request_state` and is resuming from a different process entirely (**[Driving the loop yourself](../handlers/multi-round-trip.md#driving-the-loop-yourself)**). Any of them is "a different worker". + +The fix is one argument. It has **two** halves. + +```python title="server.py" hl_lines="3 13 15" +--8<-- "docs_src/deploy/tutorial003.py" +``` + +* **`keys=[...]`** is the half everyone finds. Give every instance the same secret (at least 32 bytes of it), and every instance can unseal what any sibling minted. `keys[0]` seals and every key in the list unseals, which is the rotation ring; **[Rotating keys](../handlers/multi-round-trip.md#rotating-keys)** is how you turn it without downtime. +* **The server's name** is the half almost nobody finds, and the reason cross-instance retries still fail after you share the key. Every sealed token carries the server's `name` as an **audience claim**, checked strictly on the way back in. Two instances built from the same code have the same name and never notice it. Name them apart (`MCPServer(f"billing-{POD}")` reads like good observability hygiene), and every cross-instance retry is refused exactly as above, shared key or not. The log says `audience` instead of `unknown key`; the client cannot tell the difference. + +Mint the secret once and hand the same value to every instance. This is the command the SDK's own error message tells you to run if you pass it fewer than 32 bytes: + +```console +python -c "import secrets; print(secrets.token_hex(32))" +``` + +!!! warning "Same keys, *and* the same name" + A multi-instance deployment must share both. If per-instance names are load-bearing for you, + give the fleet one explicit audience instead: `RequestStateSecurity(keys=[...], audience="billing")`. + Every instance then mints and accepts under `"billing"` no matter what it is called. + +Everything else about the seal is **[Protecting `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**: what it binds, the per-round `ttl` (600 seconds by default), bringing your own codec, why the unconfigured default is exactly right on `stdio`. This page's whole contribution is a two-item checklist: *same keys, same name.* + +!!! info + You are on this path even if you have never typed `InputRequiredResult`. A tool whose parameters + use `Resolve(...)` (**[Dependencies](../handlers/dependencies.md)**) is a multi-round-trip tool, + and the SDK mints and seals its `request_state` for it. Same default key, same failure across + workers, same fix. + +## Change notifications across replicas + +A client's `subscriptions/listen` stream is one long-lived response, so it is pinned to one replica for its whole life. A `ctx.notify_resource_updated(...)` published on a **different** replica has to reach it. + +The seam between the two is the `SubscriptionBus`. Whatever bus you give a server is the one every publish goes into and every open stream listens on, so hand the same bus to every replica: + +```python title="server.py" hl_lines="2 7 9" +--8<-- "docs_src/deploy/tutorial004.py" +``` + +Nothing about the fan-out cares which server object a stream is attached to. Two servers holding one `InMemorySubscriptionBus` already behave this way: open a listen stream on one, `edit_note` on the other, and the stream hears about it. That in-memory bus only spans server objects inside one process, which makes it the model, not the deployment: + +* Across real processes, **the SDK ships no bus that can help you.** `SubscriptionBus` is a two-method `Protocol` (`publish` and `subscribe`) that you implement over your own pub/sub backend (Redis, NATS, whatever you already run) and pass as `MCPServer(subscriptions=...)`. **[Subscriptions](../handlers/subscriptions.md#scaling-past-one-process)** has the sketch and the contract. +* The bus carries four small typed events, never JSON-RPC. Acknowledgment, filtering, and stream lifecycle stay in the SDK, so your bus cannot break the protocol; it can only move events between processes. +* Streams are **not** resumable and events are **not** replayed. Losing a replica drops its streams; the clients re-listen and re-fetch. There is no event store to share and nothing else to configure. This is the one place where scaling out is genuinely just more of the same. + +## What the SDK does not give you + +An `MCPServer` is a protocol implementation, not an application server. The deployment knobs you go looking for next are missing on purpose: + +* **No `workers=`.** `mcp.run("streamable-http")` starts exactly one uvicorn process, and that is all it will ever start. Multi-process is `streamable_http_app()` handed to whatever you already deploy ASGI with: `uvicorn --workers`, gunicorn, your platform's process manager. This page is deliberately not a tutorial for any of them; their documentation is better than a copy of it here would be. +* **No health-check route.** `@mcp.custom_route("/health", methods=["GET"])` is the whole answer, and it is never authenticated even when the rest of the server is. That is right for a liveness probe, wrong for anything private. **[Add to an existing app](asgi.md#custom-routes)** shows one. +* **No production settings object.** There is nowhere on `MCPServer` to write down timeouts, TLS, graceful shutdown, or connection limits, because none of those are its job. They belong to your ASGI server, and you configure them there. **[Running your server](index.md)** covers the handful of settings the constructor *does* take. +* **No shipped `EventStore`, and on 2026-07-28 no use for one.** Resumability is a feature of the legacy stateful leg; a modern exchange is one POST, one response, and nothing to resume. + +## Recap + +* Out of the box the app answers only requests addressed to localhost. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` is the go-live gate: until you pass it, every request behind a real hostname is a `421` and the reason is only in the server's log. +* On 2026-07-28 there is no session and nothing for a load balancer to be sticky on. `stateless_http=True` is a legacy-only knob because a modern request is routed and answered before that flag is ever read. +* The default `requestState` key is `os.urandom(32)`, minted per process. A multi-round-trip retry that reaches a different worker fails with `-32602` *"Invalid or expired requestState"*. +* The fix is `RequestStateSecurity(keys=[...])` **and** the same server name on every instance. The name is the token's default audience claim. Same keys, same name. +* Change notifications cross replicas through one shared `SubscriptionBus`. The SDK's only implementation is in-process; the two-method `Protocol` over your own pub/sub is yours to write. +* There is no `workers=`, no health route, no production settings object. Bring your own ASGI server. + +The other thing a real hostname needs in front of it is a token: **[Authorization](authorization.md)**. diff --git a/docs/run/index.md b/docs/run/index.md new file mode 100644 index 0000000..b3cea55 --- /dev/null +++ b/docs/run/index.md @@ -0,0 +1,148 @@ +# Running your server + +`mcp.run()` starts the server. + +The only decision you make is the **transport**: how the bytes between your server and its client actually move. + +## Pick a transport + +| Transport | What it is | When | +|---|---|---| +| `stdio` | The host launches your file as a subprocess and speaks over its stdin and stdout. | Local servers. The default. | +| `streamable-http` | A real HTTP server listening on a port. | Anything you deploy. | +| `sse` | The older HTTP transport. | You don't. | + +!!! warning + SSE was superseded by Streamable HTTP in the 2025-03-26 protocol revision. + `mcp.run(transport="sse")` still works, with its own `sse_path=` and `message_path=` + options, but it exists for clients that haven't moved. Don't build anything new on it. + +## `mcp.run()` + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/run/tutorial001.py" +``` + +* `run()` is synchronous. It blocks for the life of the server. +* With no argument, the transport is `stdio`. +* It sits under `if __name__ == "__main__":` because everything that loads your server (`mcp dev`, `mcp run`, `mcp install`, your tests) **imports** this file. The guard keeps an import from turning into a running server. + +### stdio + +There is nothing to configure. The host starts your file as a child process, writes requests to its stdin, and reads responses from its stdout. + +Run it yourself and you see the consequence: + +```console +python server.py +``` + +Nothing prints, and it doesn't return. It is waiting on stdin for a host to speak first. + +That also means stdout **is the wire**. A stray `print()` corrupts the stream; the `logging` module writes to stderr and is the right tool. That story is in **[Logging](../handlers/logging.md)**. + +### Try it + +```console +uv run mcp dev server.py +``` + +The Inspector does exactly what a real host does: it launches `server.py` as a subprocess and connects to it over stdio. + +You never gave it a port. There isn't one. + +## Streamable HTTP + +To put the same server on a port instead, name the transport (and its options) in `run()`: + +```python title="server.py" hl_lines="13" +--8<-- "docs_src/run/tutorial002.py" +``` + +That one line builds a Starlette app and serves it with uvicorn. Clients connect to `http://127.0.0.1:3001/mcp`. + +Each transport has its own keyword arguments, all on `run()`: + +* `host` / `port`: where to listen. Defaults `127.0.0.1` and `8000`. +* `streamable_http_path`: where the MCP endpoint lives. Default `/mcp`. +* `json_response=True`: answer with plain JSON instead of an SSE stream. +* `stateless_http=True`: a fresh transport per request, no session tracking. +* `event_store`, `retry_interval`, `transport_security`: resumability and DNS-rebinding protection. They can wait, until you deploy somewhere other than localhost; **[Deploy & scale](deploy.md)** covers `transport_security`. + +!!! warning + Transport options go to `run()`, **not** to `MCPServer(...)`. The constructor describes what + your server *is*: name, version, instructions. `run()` describes how it is served. Get it + backwards and Python answers before MCP is even involved: + + ```text + TypeError: MCPServer.__init__() got an unexpected keyword argument 'port' + ``` + +`run()` is the short road. The moment you need more (your server mounted inside an existing app, two servers in one process, CORS for browser clients), you build the ASGI app yourself and hand it to any ASGI host. That is **[Add to an existing app](asgi.md)**. + +## Server settings + +A couple of things about running are not about the transport. They are constructor arguments: + +```python title="server.py" hl_lines="3" +--8<-- "docs_src/run/tutorial003.py" +``` + +* `log_level`: handed to `logging.basicConfig()` the moment `MCPServer(...)` is constructed. That configures the **root** logger, so it sets the level for your own loggers too, not just the SDK's. Default `"INFO"`. +* `debug`: forwarded to the Starlette app that the HTTP transports build. Default `False`. + +Both land on `mcp.settings`, which you can read back at runtime. + +## The `mcp` command + +The `[cli]` extra installs a small command-line tool around all of this. + +`mcp dev` runs your server under the **MCP Inspector**: + +```console +uv run mcp dev server.py +uv run mcp dev server.py --with pandas --with numpy +uv run mcp dev server.py --with-editable . +``` + +`--with` adds packages to the environment it builds; `--with-editable` installs your own package into it. It needs `npx` on your `PATH`: the Inspector is a Node.js app. + +`mcp run` imports the file, finds the server object (a module-level `mcp`, `server`, or `app`), and calls `run()` on it: + +```console +uv run mcp run server.py +uv run mcp run server.py:bookshop +``` + +The `:` suffix names the object when it isn't called `mcp`, `server`, or `app`. + +Your `if __name__ == "__main__":` block never executes here: `mcp run` calls `run()` itself, and the only option it forwards is `--transport`. + +`mcp install` registers the server with **Claude Desktop**, so the app launches it for you: + +```console +uv run mcp install server.py --name "Bookshop" +uv run mcp install server.py -v API_KEY=abc123 -f .env +``` + +`-v KEY=VALUE` and `-f .env` record environment variables in that entry. Claude Desktop starts your server in its own process. Your shell's environment is not there. + +Claude Desktop is the only host `mcp install` knows. Every other host (Claude Code, Cursor, VS Code) takes the same launch command in its own config file, and **[Connect to a real host](../get-started/real-host.md)** has each one. + +`mcp version` prints the installed SDK version. + +!!! tip + `mcp dev` and `mcp run` only understand `MCPServer`. If you build with the low-level `Server`, + you run it yourself. See **[The low-level Server](../advanced/low-level-server.md)**. + +## Recap + +* A **transport** is how bytes reach your server: `stdio` for a local subprocess, `streamable-http` for a port. SSE is superseded. +* `mcp.run()` picks the transport. With no argument it is `stdio`, and it blocks. +* Every transport option (`host`, `port`, `streamable_http_path`, ...) is an argument to `run()`, never to `MCPServer(...)`. +* Keep `run()` under `if __name__ == "__main__":`. Everything that loads your server imports the file first. +* `log_level=` and `debug=` are constructor arguments; they land on `mcp.settings`. +* `mcp dev` for the Inspector, `mcp run` to execute a file, `mcp install` for Claude Desktop, `mcp version` for the version. +* The transport never changes what your server *is*: all three files on this page expose the identical tool. + +When `run()` itself is the limit (your server inside an app that already exists), it is **[Add to an existing app](asgi.md)**. A real hostname and more than one worker is **[Deploy & scale](deploy.md)**. And if some of your clients are still on spec version 2025-11-25 or earlier, **[Serving legacy clients](legacy-clients.md)** is the good news. diff --git a/docs/run/legacy-clients.md b/docs/run/legacy-clients.md new file mode 100644 index 0000000..c7a1096 --- /dev/null +++ b/docs/run/legacy-clients.md @@ -0,0 +1,120 @@ +# Serving legacy clients + +MCP has two protocol eras: the `initialize`-handshake era, up to spec version `2025-11-25`, and the modern era, `2026-07-28`. **[Protocol versions](../protocol-versions.md)** is the page on the split itself. + +This page is about the server side of that split, and the answer fits in one sentence: **the `streamable_http_app()` you already deploy serves both.** + +The SDK routes every request by its `MCP-Protocol-Version` header. A request naming `2026-07-28` goes to the modern handler. A request naming a handshake-era version, or carrying no header at all (which is how a pre-2026 client's `initialize` arrives), goes to the transport those clients expect: `initialize` handshake, sessions and all. It happens per request, before your code, on the one app. + +So a legacy client is not something you build *for*. It is something that connects *to* the server you already wrote. You configure nothing. + +!!! note + Nothing, literally. There is no `legacy=` option, no version allowlist, no way to reject or + disable an era: not on `streamable_http_app()`, not on `run()`, not on the session manager. + Both eras are always on. The nearest thing to a per-era switch in that signature is + `stateless_http`, and it is most of this page. + +## One handler, both eras + +Here is a tool that has to ask the user something, and both eras of client calling it: + +```python title="server.py" hl_lines="24 37-38" +--8<-- "docs_src/legacy_clients/tutorial001.py" +``` + +`reserve` needs one thing the model didn't supply: how many copies. `Annotated[..., Resolve(ask_quantity)]` is how a tool declares that (**[Dependencies](../handlers/dependencies.md)** is that whole story). Nothing in `reserve` names a version, checks a capability, or branches. + +The two clients are open **at the same time**, on the same `mcp` object. `mode="legacy"` runs the `initialize` handshake: the exact connection a pre-2026 client opens. The other one takes the default and lands on `2026-07-28`. + +```text +2025-11-25 {'result': "Reserved 2 of 'Dune'."} +2026-07-28 {'result': "Reserved 2 of 'Dune'."} +``` + +Same server, same handler, same answer. That is the whole feature. + +It is worth pausing on *how*, because the two clients were asked the same question over two completely different wires. The `2026-07-28` connection has no channel for the server to send a request on, so `Resolve` returned the question inside the tool result and the client retried the call with the answer (**[Multi-round-trip requests](../handlers/multi-round-trip.md)**). The `2025-11-25` connection has no such thing; there, `Resolve` sent a live `elicitation/create` request mid-call and waited. You wrote neither. `Resolve` reads the connection's negotiated version and picks; your tool body sees an `AcceptedElicitation` either way. + +!!! tip + That era-portability is *why* `Resolve` is the API to build on. Its older sibling `ctx.elicit()` + (**[Elicitation](../handlers/elicitation.md)**) only ever sends `elicitation/create`, so it only + ever works on a legacy connection. On a `2026-07-28` one the call fails. If a tool still uses + it, the fix is the one you see above, not a version check. + +## What a legacy session costs you + +The routing is free. The session is not. + +A `2026-07-28` connection is **sessionless**: every request stands alone, and the modern handler never issues an `Mcp-Session-Id`. A legacy connection is the opposite. The moment a pre-2026 client sends `initialize`, the SDK mints an `Mcp-Session-Id`, returns it in a response header, and keeps a live record behind it for the client's later requests to find: the negotiated version, the open streams, a background task driving the session. + +That record is a **plain in-process `dict`**. There is no distributed session store and no way to plug one in. + +On one worker that is invisible. On two, it is the whole problem: a request that carries an `Mcp-Session-Id` and lands on a worker that didn't mint it finds nothing in that dict, and the answer is a `404` (`Session not found`), not the tool result. So the moment you run more than one worker, **legacy clients need sticky routing**: every request in a session has to reach the process that started it. Modern clients never do; they have no session to be sticky to. **[Deploy & scale](deploy.md)** covers stickiness and everything else about running more than one of these. + +!!! warning + `event_store=` looks like the fix and is not. It is **resumability** (replaying missed SSE + events to a client reconnecting to the *same* session), not a session store. It never makes a + session reachable from another process. + +## The one knob: `stateless_http` + +If stickiness is a cost you refuse to pay, there is exactly one thing you can change. + +```python title="server.py" hl_lines="28" +--8<-- "docs_src/legacy_clients/tutorial002.py" +``` + +That is the server from the top of the page plus one keyword. `stateless_http=True` makes the legacy leg build a throwaway, per-request session instead: no `Mcp-Session-Id` issued, nothing remembered between requests, so any worker can serve any request and the load balancer can do whatever it likes. + +Two things about it matter more than what it does. + +**It only touches the legacy leg.** Requests are routed on the version header *before* `stateless_http` is read, so the modern path never sees it. A `2026-07-28` connection is already sessionless and is exactly the same under either value. + +**It costs both server-to-client channels on that leg.** A session that lives for one `POST` has no stream for the server to push a request down and no standalone stream for it to push notifications down. Every server-initiated request raises `NoBackChannelError`: `ctx.elicit()`, the retired sampling and roots calls (**[Deprecated features](../deprecated.md)**), and, yes, `Resolve` asking a *legacy* client its question. Notifications don't even get an error; they are silently dropped. + +!!! check + Do the wrong thing. `reserve` is the exact tool that just served both clients. Deploy it with + `stateless_http=True`, connect the same two clients over HTTP, and call it from each. + + The modern client still gets `Reserved 2 of 'Dune'.` The modern leg didn't change. + + The legacy client's call does not come back as an `is_error` result the model could read. + The whole request fails, as a top-level protocol error: + + ```text + mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests. + ``` + + `Resolve` did not save you. On a `2025-11-25` connection it *has* to send `elicitation/create`, + and the channel it needs is exactly the thing `stateless_http=True` gave away. Era-portable + code is not back-channel-free code. + +So it is a real trade, and it only exists on the legacy leg: **sessionful and sticky, or stateless and one-directional.** If your tools never call back into the client, `stateless_http=True` is free and you should take it. If they do, keep the sessions and keep the routing sticky. + +## Where your code actually forks + +Almost nowhere. + +Tools, resources, prompts, structured output, progress, errors: none of them care which era called. The `initialize` handshake, the `Mcp-Session-Id`, the standalone stream, the `DELETE` that ends a session: the SDK owns all of it, and a handler never sees any of it. Interactive input is *the* place the eras genuinely differ on the wire, and `Resolve` exists so that it is not your problem: you just watched one tool serve both. + +There is exactly one thing left, and it is **change notifications**, because the two eras listen on different pipes: + +* A `2026-07-28` client opens a `subscriptions/listen` stream and reads the subscriptions bus. `ctx.notify_resource_updated()` (and `notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`) publish there, and *only* there. **[Subscriptions](../handlers/subscriptions.md)** is that page. +* A legacy client reads the standalone stream its session keeps open. `ctx.session.send_resource_updated()` (and `send_tool_list_changed()` and friends) write to the *connection* that carried the request: for a legacy session, that is its standalone stream. For a modern HTTP request there is no such channel, and the notification is quietly dropped. + +Over HTTP, neither call reaches the other era's clients. To tell everyone, call both: + +```python title="server.py" hl_lines="19-20" +--8<-- "docs_src/legacy_clients/tutorial003.py" +``` + +Two lines, no `if`, no version check, and you are done. That is the entire list of things a handler does differently because a legacy client exists. + +## Recap + +* One `streamable_http_app()` serves both protocol eras. The SDK routes each request by its `MCP-Protocol-Version` header; there is nothing to configure and no era knob to look for. +* A legacy client costs you a session: an in-process `Mcp-Session-Id` record with no distributed store behind it. More than one worker means **sticky routing**, or the wrong worker answers `404 Session not found`. **[Deploy & scale](deploy.md)** has the multi-worker story. +* `stateless_http=True` is the one knob, and it is **legacy-leg-only**. It buys free load balancing for legacy clients at the price of both server-to-client channels on that leg: server-initiated requests raise `NoBackChannelError` (a top-level error at the client, not an `is_error` result), and notifications are dropped. +* A `2026-07-28` connection is sessionless either way. `stateless_http` never touches it. +* Your handler code forks on era in exactly one place: change notifications. `ctx.notify_*` reaches `subscriptions/listen` clients; `ctx.session.send_*` reaches legacy sessions. Call both. +* Everything else (including asking the user for input, via `Resolve`) is era-portable by construction. Write the modern thing once. diff --git a/docs/run/opentelemetry.md b/docs/run/opentelemetry.md new file mode 100644 index 0000000..c36f5ce --- /dev/null +++ b/docs/run/opentelemetry.md @@ -0,0 +1,107 @@ +# OpenTelemetry + +Your server is already traced. You don't have to add anything. + +Every server you create emits an [OpenTelemetry](https://opentelemetry.io/) span for every +message it handles. You didn't write that, and you don't import it. It is there the moment you +call `MCPServer(...)`. + +```python title="server.py" +--8<-- "docs_src/opentelemetry/tutorial001.py" +``` + +That is a complete, traced server. Call `search_books` and a span is created for it. The same is +true for the low-level `Server`: the tracing lives on both. + +## What you get + +Every inbound message becomes a `SERVER` span named after the method and its target. So a +`tools/call` for `search_books` is the span `tools/call search_books`, and a bare `tools/list` +is just `tools/list`. + +Each span carries a few attributes: + +* `mcp.method.name` and `mcp.protocol.version`, on every span. +* `jsonrpc.request.id`, on a request (a notification has none). +* A handler that raises sets the span status to error. So does a tool result with `is_error=True`. + +And because tracing a tool call is such a common thing to want, `tools/call` spans speak +OpenTelemetry's [GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/): + +* `gen_ai.operation.name`, set to `"execute_tool"`. +* `gen_ai.tool.name`, set to the tool being called. + +A `prompts/get` span gets `gen_ai.prompt.name` in the same spirit. The list methods carry no +`gen_ai.*` keys, because there is nothing to name. + +!!! tip + Those GenAI attributes are the reason a tracing UI groups your tool calls the way it groups + any other agent's. You get that grouping for free, with no extra code. + +## It costs nothing until you want it + +Here is the part that makes "on by default" a comfortable default. + +The SDK depends only on `opentelemetry-api`, the lightweight half of OpenTelemetry. With no SDK +and no exporter installed, creating a span is a no-op. So the spans your server is emitting right +now cost you almost nothing, and nobody is collecting them. + +The day you want to *see* them, you install the other half and point it somewhere: + +```console +uv add opentelemetry-sdk opentelemetry-exporter-otlp +``` + +Configure an exporter the usual OpenTelemetry way, and every span the SDK has been quietly +creating lights up. Your server code does not change. Not one line. + +!!! info + [Pydantic Logfire](https://logfire.pydantic.dev/) is one such backend, and it does the + configuration for you: `pip install logfire`, `logfire.configure()`, and your MCP spans show + up in the live view. It is built on OpenTelemetry, so anything below applies to it too. + +## Traces that cross the wire + +A trace is most useful when it follows a request from the client into the server, in one +connected picture. + +When the client and the server both run the SDK, that connection is automatic. The client injects +the [W3C trace context](https://www.w3.org/TR/trace-context/) into the request, and the server +reads it back out, so the server span nests under the client span in the same trace. This is +[SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414), and you get it without +asking. + +If the inbound message carries no trace context, for example a request from a client that is not +the SDK, the server span simply parents to whatever span is already current on the server, rather +than starting a brand-new orphan trace. + +## Turning it off + +Tracing is a middleware, the first one on your server's list. If you really want a server that +emits no spans, take it off: + +```python +from mcp.server._otel import OpenTelemetryMiddleware + +mcp._lowlevel_server.middleware[:] = [ + m for m in mcp._lowlevel_server.middleware if not isinstance(m, OpenTelemetryMiddleware) +] +``` + +!!! warning + That import has a leading underscore, and that is on purpose. The class is provisional, the + same way [`Server.middleware`](../advanced/middleware.md) is provisional, so the import path is something + you should expect to change. You almost never need this: with no exporter installed the spans + are free, so the usual answer is to leave them on and not install an exporter. + +## Recap + +* Every `MCPServer` and every low-level `Server` emits one `SERVER` span per inbound message, out + of the box. You write nothing. +* Spans carry `mcp.method.name` and `mcp.protocol.version`; `tools/call` and `prompts/get` also + carry GenAI attributes so your tool calls group like any other agent's. +* It costs nothing until you install an OpenTelemetry SDK and an exporter, and then it lights up + with no change to your server. +* Client-to-server trace context propagates automatically when both sides run the SDK. + +The thing that decides whether a request runs at all is **[Authorization](authorization.md)**. diff --git a/docs/servers/completions.md b/docs/servers/completions.md new file mode 100644 index 0000000..b7b8750 --- /dev/null +++ b/docs/servers/completions.md @@ -0,0 +1,125 @@ +# Completions + +A client building a UI on top of your server wants to autocomplete argument values as the user types: language names, repository names, file paths. + +**Completions** are how your server supplies those suggestions. + +## Something worth completing + +Completions apply to exactly two things: the arguments of a **prompt** and the parameters of a **resource template**. So start with a server that has one of each: + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/completions/tutorial001.py" +``` + +Nothing here is about completions yet. + +* `review_code` takes a `language`. A user shouldn't have to guess which spellings you accept. +* `github_repo` takes an `owner` and a `repo`. Free-text boxes for both make a bad form. + +## The completion handler + +Add **one** function decorated with `@mcp.completion()`: + +```python title="server.py" hl_lines="22-30" +--8<-- "docs_src/completions/tutorial002.py" +``` + +* There is one handler per server. Every completion request lands here, and you branch on what's being completed. +* It must be `async def`: the SDK awaits it. +* It receives three arguments: + * `ref`: *which* prompt or resource template, as a `PromptReference` or a `ResourceTemplateReference`. `isinstance` is how you tell them apart. + * `argument`: `argument.name` is the argument being completed, `argument.value` is what the user has typed so far. + * `context`: the arguments already resolved. Ignore it for now. +* You return a `Completion(values=[...])`, or `None` when you have nothing to offer. + +!!! tip + `argument.value` is the prefix the user has typed. The SDK does **not** filter for you: whatever + you put in `values` is what the UI shows. The `startswith` is yours to write. + +### Try it + +Drive it with the in-memory `Client` from **[Testing](../get-started/testing.md)**. Call +`client.complete()` with `ref=PromptReference(name="review_code")` and +`argument={"name": "language", "value": "py"}`: + +```python +result.completion.values # ['python'] +``` + +* `ref` is the same reference type your handler receives. +* `argument` is a plain dict with exactly two keys, `name` and `value`. + +Send an empty `value` and you get the whole list back. `lang.startswith("")` is true for every language: + +```python +result.completion.values # ['go', 'javascript', 'python', 'rust', 'typescript'] +``` + +Ask about `code` (an argument your handler doesn't recognise) and it returns `None`, which the SDK turns into an empty list: + +```python +result.completion.values # [] +``` + +`None` means *"no suggestions"*, never an error. A UI falls back to a plain text box. + +## A capability you never declared + +Registering the handler is the declaration. Connect a client and look: + +```python +client.server_capabilities.completions # CompletionsCapability() +``` + +You didn't list `completions` anywhere. The SDK saw the handler and declared the capability for you. Every *optional* capability works this way: the handler is the declaration. (The three primitives are not optional: `MCPServer` always declares those, handlers or not.) + +!!! check + Go back to the first `server.py` (the one with no handler) and ask it anyway. The call fails + with a JSON-RPC error: + + ```text + Method not found + ``` + + And `client.server_capabilities.completions` is `None`. That's the point of the capability: a + well-behaved client checks it and never sends the request you can't answer. + +## Dependent arguments + +`github://repos/{owner}/{repo}` has two parameters, and the useful values for `repo` depend on which `owner` was picked first. + +That's what `context` is for. It carries the arguments the user has **already resolved**: + +```python title="server.py" hl_lines="9-12 35-39" +--8<-- "docs_src/completions/tutorial003.py" +``` + +* The new branch fires for the template's `repo` parameter. +* `context.arguments` is a `dict[str, str] | None` of the values picked so far (here, `owner`). +* No `owner` yet means no sensible suggestions, so the handler returns `None`. + +The client sends those resolved values with `context_arguments=`. This time `ref` is a +`ResourceTemplateReference(uri="github://repos/{owner}/{repo}")`. Ask for `repo` with an +empty `value` and pass `context_arguments={"owner": "modelcontextprotocol"}`: + +```python +result.completion.values # ['python-sdk', 'typescript-sdk', 'inspector'] +``` + +Drop `context_arguments=` and the same call returns `[]`. The handler can't know which repos to offer until it knows the owner. + +!!! info + `Completion` also takes `total=` and `has_more=`. Set them when `values` is a slice of a longer + list, so a UI can show *"and 200 more"*. Most handlers never need them. + +## Recap + +* Completions are suggestions for **prompt arguments** and **resource template parameters**. Nothing else. +* `@mcp.completion()` registers the one handler. It's `async def (ref, argument, context) -> Completion | None`. +* Branch on `isinstance(ref, ...)` and on `argument.name`. Filter by `argument.value` yourself. +* `None` becomes an empty list. It is never an error. +* `context.arguments` holds the already-resolved values; the client supplies them as `context_arguments=`. +* The `completions` capability appears the moment you register the handler. Without it, the request is `Method not found`. + +Suggestions help while the user is still *filling in* a prompt or template; to ask them a question in the *middle* of a tool call, you want **[Elicitation](../handlers/elicitation.md)**. Everything a tool can return besides text is **[Images, audio & icons](media.md)**. diff --git a/docs/servers/handling-errors.md b/docs/servers/handling-errors.md new file mode 100644 index 0000000..0cb0a7d --- /dev/null +++ b/docs/servers/handling-errors.md @@ -0,0 +1,134 @@ +# Handling errors + +A tool can fail in two ways, and the SDK treats them very differently. + +Raise an ordinary exception and the **model** sees it. Raise `MCPError` and the **protocol** sees it. + +This page is about choosing. + +## An error the model can fix + +Take a tool that looks something up, and let the lookup miss: + +```python title="server.py" hl_lines="11-12" +--8<-- "docs_src/handling_errors/tutorial001.py" +``` + +There is nothing MCP about those two lines. `get_author` raises a plain `ValueError`, the way any Python function would. + +Call it with a title that isn't in the catalog and look at the result: + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool get_author: No book titled 'Nothing' in the catalog.")] +result.structured_content # None +``` + +* The request **succeeded**. There is a result; nothing was raised at the caller. +* `is_error` is `True`, and your exception's message (prefixed with the tool name) is in `content`, exactly where the model reads. +* `structured_content` is `None`. A failed call has no return value to structure. + +This is a **tool error**, and it is the default for *any* exception your tool raises. It is also almost always what you want. + +The model is the one calling your tool. It picked the arguments. So a tool error is a turn in the conversation: the model reads *"No book titled 'Nothing' in the catalog."*, realises it guessed the title wrong, and calls again with a better one. You wrote one `raise` and got a self-correcting agent. + +!!! tip + Never `return` an error message from a tool. A returned string has `is_error=False`, so to the + model (and to every client UI) it looks like the tool worked and that string was the answer. + `raise`. The flag is the signal. + +## An error the model cannot fix + +Now swap `ValueError` for `MCPError`. + +```python title="server.py" hl_lines="1 3 15" +--8<-- "docs_src/handling_errors/tutorial002.py" +``` + +`MCPError` is the SDK's **protocol error**. It is the one exception the tool wrapper does *not* catch: it propagates, and the whole `tools/call` request fails with a JSON-RPC error instead of a result. + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog." +} +``` + +* There is **no result**. No `content`, no `is_error`: nothing for the model to read. +* The **host** application gets the error instead, the same way it would if the tool didn't exist at all. +* `code`, `message`, and `data` arrive intact. `INVALID_PARAMS` is `-32602`; `mcp_types` exports it and the other JSON-RPC error codes (`INVALID_REQUEST`, `INTERNAL_ERROR`, ...) as constants so you never type a magic number. + +!!! check + Same lookup, same miss, but now the call *raises* on the client side instead of returning: + + ```text + mcp.shared.exceptions.MCPError: No book titled 'Nothing' in the catalog. + ``` + + The first version handed the model a sentence it could react to. This one hands it nothing. + For `get_author` that is strictly worse, which is the point of the next section. + +## Which one to raise + +The two paths answer two different questions. + +* **Raise any exception** for a failure of *execution*: the thing your tool tried to do didn't work. The model chose the call, so the model should see the consequence and get a chance to recover. A misspelled title, an upstream API that timed out, a row that doesn't exist: all tool errors. +* **Raise `MCPError`** when the *request itself* should be rejected: the client is missing a capability your tool depends on, the server isn't in a state to serve anyone, the caller skipped a required step. No retry from the model fixes any of those, so there is nothing to gain from handing it the message. + +One question decides it: **could a smarter model have avoided this?** Yes -> ordinary exception. No -> `MCPError`. + +By that test, the second version of `get_author` made the wrong choice: a better title fixes it, so the model deserved to see the message. It's there to show you the mechanism, not to recommend it. + +!!! info + `MCPError` lives at `from mcp import MCPError` and takes `code`, `message`, and an optional + `data` payload. Whatever you put in them is what the client receives: the SDK forwards a raised + `MCPError` verbatim instead of sanitising it. + +## A resource that doesn't exist + +Resources draw the same line, and ship one named exception for the common case. + +```python title="server.py" hl_lines="2 13" +--8<-- "docs_src/handling_errors/tutorial003.py" +``` + +`books://{title}` is a **template**. It matches *any* title, so "the URI is well-formed" and "the book exists" are two different questions, and only your function can answer the second one. + +When it can't, raise `ResourceNotFoundError`. The SDK turns it into the protocol error the spec assigns to a missing resource: `-32602` with the requested URI in `data`, so the client knows *which* read failed. + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog.", + "data": {"uri": "books://Nothing"} +} +``` + +Notice there is no `is_error=True` half-result here. A resource read either returns contents or fails: resources have only the protocol path. Templates and everything else about resources live in **[Resources](resources.md)**. + +## Errors you never raise + +A bad argument never reaches your function. + +Send `get_author` a `title` that isn't a string and the SDK rejects it against the input schema **before** calling you, as the same kind of `is_error=True` tool error the model can read and correct. **[Tools](tools.md)** shows the same rejection with a `Field(le=50)` constraint. + +It means a whole class of `raise` statements you don't write: don't re-validate your own type hints. + +!!! info + Everything on this page is what a **client** sees, and the in-memory `Client` you'll write + tests with sees exactly the same thing. Even `raise_exceptions=True` doesn't turn a tool error + back into a traceback: by the time that flag could act, your exception is already the + `is_error=True` result. Assert on the result. **[Testing](../get-started/testing.md)** covers the pattern. + +## Recap + +* Raise **any exception** in a tool -> the call returns `is_error=True` with your message in `content`. The model reads it and can retry. This is the default. +* Raise **`MCPError`** -> the call itself fails with a JSON-RPC error. The model sees nothing; the host deals with it. `code`, `message`, and `data` survive intact. +* The deciding question: *could a smarter model have avoided this?* Yes -> exception. No -> `MCPError`. +* `ResourceNotFoundError` from a resource handler -> the protocol's `-32602`, with the URI in `data`. +* Bad arguments are rejected against the schema before your function runs; you don't `raise` for those. +* `from mcp import MCPError`; the error-code constants come from `mcp_types`. + +Errors handled. That is everything a server *exposes*. What every handler can read, and do back to the client while it runs, is the next section: **[Inside your handler](../handlers/index.md)**. + +The exact text of the SDK errors you are most likely to meet, what each means, and the one-move fix for each is **[Troubleshooting](../troubleshooting.md)**. diff --git a/docs/servers/index.md b/docs/servers/index.md new file mode 100644 index 0000000..72eda00 --- /dev/null +++ b/docs/servers/index.md @@ -0,0 +1,30 @@ +# Servers + +An `MCPServer` exposes three primitives to a connected client. They differ by who +decides to use them: + +* A **[tool](tools.md)** is an action the *model* picks and calls. This is + the page most people want first, and + **[Structured Output](structured-output.md)** is its reference companion: + everything about the shape of what a tool returns. +* A **[resource](resources.md)** is read-only data the *application* + chooses to read. **[URI templates](uri-templates.md)** is its reference + companion: the full addressing syntax and the path-safety rules. +* A **[prompt](prompts.md)** is a message template a *person* invokes by + name, from a menu or a slash command. + +Around the three primitives, the rest of what a server declares: + +* **[Completions](completions.md)** is server-side autocomplete for prompt + and resource-template arguments. +* **[Images, audio & icons](media.md)** covers everything a tool can + return besides text, and the icons a client shows next to your server. +* **[Handling errors](handling-errors.md)** explains the difference between an + error the model can recover from and one it must never see. + +Every page here stands on its own; jump straight to the one you need. If you haven't +built a server yet, start with **[First steps](../get-started/first-steps.md)** instead. + +What happens *inside* the functions you register (the `Context`, dependency injection, +asking the user for more input mid-call) is the next section, +**[Inside your handler](../handlers/index.md)**. diff --git a/docs/servers/media.md b/docs/servers/media.md new file mode 100644 index 0000000..e5e8a76 --- /dev/null +++ b/docs/servers/media.md @@ -0,0 +1,108 @@ +# Media + +Text is not the only thing a tool can return. + +The SDK ships two helpers for binary results (**`Image`** and **`Audio`**) and an **`Icon`** type for giving your server, tools, resources, and prompts a face in the client's UI. + +## Returning an image + +Annotate the return type as `Image` and return one: + +```python title="server.py" hl_lines="14 16" +--8<-- "docs_src/media/tutorial001.py" +``` + +* `Image` takes exactly one of `data` (raw bytes) or `path` (a file to read). +* `format="png"` becomes the MIME type the client sees: `image/png`. +* The bytes here are a one-pixel placeholder so the file runs on its own. In a real server they come from Pillow, matplotlib, a headless browser, or anything else that hands you `bytes`. + +`Image` is an SDK convenience, not a protocol type. On the wire your return value becomes an **`ImageContent`** block (your bytes base64-encoded, plus the MIME type): + +```python +result.content # [ImageContent(type="image", data="iVBORw0KGgoAAAANSUhEUg...", mime_type="image/png")] +result.structured_content # None +``` + +Two things to notice: + +* `data` is base64. You returned raw `bytes`; the SDK did the encoding. +* `structured_content` is `None`. An `Image` is content for the model to look at, not data for the application to parse: there is no output schema. (Contrast **[Structured Output](structured-output.md)**, where the return annotation *is* the schema.) + +!!! info + `ImageContent` and `AudioContent` live in `mcp_types`, right next to the `TextContent` + that a plain `str` result becomes (**[Tools](tools.md)**). A tool result is a list of content blocks; `Image` and `Audio` are + the shortest way to produce the two binary kinds. + +### Try it + +```console +uv run mcp dev server.py +``` + +Open the **Tools** tab and call `logo`. The result is not a string: it is an `image` content block, and the Inspector renders it as a picture. You returned `bytes`; everything between that and the pixels on screen was the SDK. + +## Returning audio + +`Audio` is the same shape: + +```python title="server.py" hl_lines="21-24" +--8<-- "docs_src/media/tutorial002.py" +``` + +The result is an **`AudioContent`** block: + +```python +result.content # [AudioContent(type="audio", data="UklGRjQAAABXQVZFZm1...", mime_type="audio/wav")] +result.structured_content # None +``` + +Same deal: raw bytes in, base64 and a MIME type out, no output schema. + +## Bytes or a file + +Both helpers also accept `path=` instead of `data=`. The file is read when the result is built, and the MIME type is guessed from the suffix: + +* `Image`: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`. +* `Audio`: `.wav`, `.mp3`, `.ogg`, `.flac`, `.aac`, `.m4a`. + +A suffix it doesn't recognise falls back to `application/octet-stream`. + +!!! check + With `data=` there is no filename, so there is nothing to guess from. Forget `format=` and + the SDK falls back to a default: `image/png` for images, `audio/wav` for audio. Build an + `Audio` from MP3 bytes that way and the client is told `mime_type="audio/wav"`, then + faithfully fails to decode it. When you pass `data=`, pass `format=`. + +## Icons + +An `Icon` is metadata, not content. It doesn't carry the image; it points at one with a URI, and a client may fetch it and show it next to your server's name, a tool, a resource, or a prompt. + +```python title="server.py" hl_lines="5-6 8 11 17" +--8<-- "docs_src/media/tutorial003.py" +``` + +* `src` is a URI the client can resolve: `https:`, or a `data:` URI if you want the icon embedded with no extra fetch. +* `mime_type` and `sizes` (`"48x48"`, or `"any"` for a scalable format) let the client pick the right one when you offer several. +* `theme="light"` or `theme="dark"` marks an icon for one colour scheme. + +The same `icons=[...]` keyword is accepted by `MCPServer(...)`, `@mcp.tool()`, `@mcp.resource()`, and `@mcp.prompt()`. + +### Where a client sees them + +Icons travel with whatever they decorate. The server's arrive when the client connects, on `client.server_info`: + +```python +client.server_info.icons # [Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])] +``` + +A tool's icons are on the `Tool` object from `tools/list`, a resource's on the `Resource` from `resources/list`, a prompt's on the `Prompt` from `prompts/list`. The field is always called `icons`. + +## Recap + +* Return an `Image` or `Audio` from a tool and the client receives an `ImageContent` / `AudioContent` block: your bytes base64-encoded, with a MIME type. +* Build one from in-memory `data=` plus an explicit `format=`, or from a `path=` and let the suffix decide. +* Media results carry no `structured_content` and no output schema. +* An `Icon` is a pointer: a `src` URI plus optional `mime_type`, `sizes`, and `theme`. +* `icons=[...]` works on the server, on tools, on resources, and on prompts, and clients find them on the matching objects. + +That is everything a tool can put *into* a result. What happens when a tool *fails* (and who should find out) is **[Handling errors](handling-errors.md)**. diff --git a/docs/servers/prompts.md b/docs/servers/prompts.md new file mode 100644 index 0000000..c49860d --- /dev/null +++ b/docs/servers/prompts.md @@ -0,0 +1,150 @@ +# Prompts + +A **prompt** is a message template the user picks. + +Tools are for the model. A prompt is the opposite: the user chooses one from a menu in their client (a slash command, a button), fills in its arguments, and the rendered messages go into the conversation as if they had typed them. + +You declare one by putting `@mcp.prompt()` on a function that returns the text. + +## Your first prompt + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/prompts/tutorial001.py" +``` + +The SDK reads the same three things it reads from a tool: + +* The **name** is the function name: `review_code`. +* The **description** the client shows is the docstring: `Review a piece of code.` +* The **arguments** come from the parameters. `code` has no default, so it's required. + +That is what a client gets back from `prompts/list`: + +```json +{ + "name": "review_code", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "required": true} + ] +} +``` + +There is no JSON Schema here. Prompt arguments are a flat list of **named string values**: a form a person fills in, not a payload a model constructs. + +### Rendering it + +The client renders the template with `prompts/get`, passing the arguments. Your function runs and the `str` you return becomes **one user message**: + +```json +{ + "description": "Review a piece of code.", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Please review this code:\n\ndef add(a, b): return a + b" + } + } + ], + "resultType": "complete" +} +``` + +That is the entire life of a prompt: listed by name, rendered on demand, dropped into the chat. + +!!! check + `required` is enforced before your function runs. Render `review_code` without `code` and the + request itself fails with a JSON-RPC error (code `-32603`): + + ```text + mcp.shared.exceptions.MCPError: Internal server error + ``` + + There is no tool-style error result to hand back to a model, because no model is in the loop: + the call raises. The reason (`Missing required arguments: {'code'}`) lands in your server's log. + +### Try it + +Run the server with the MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Open the **Prompts** tab and select `review_code`. The Inspector draws a form with one required `code` field. Fill it in, render it, and you get back exactly the user message above. + +## More than one message + +A code review is one message. A debugging session is a conversation, and a prompt can seed the whole thing. + +Return a list of messages instead of a `str`: + +```python title="server.py" hl_lines="2 13-20" +--8<-- "docs_src/prompts/tutorial002.py" +``` + +* `UserMessage` and `AssistantMessage` come from `mcp.server.mcpserver.prompts.base`. Hand them a `str` and they wrap it in `TextContent` for you. The role is the class name. +* `Message` is their common base. Use it as the return annotation. + +Rendering `debug_error` now produces three messages, in order: + +```json +{ + "description": "Start a debugging conversation.", + "messages": [ + {"role": "user", "content": {"type": "text", "text": "I'm seeing this error:"}}, + {"role": "user", "content": {"type": "text", "text": "TypeError: 'int' object is not iterable"}}, + { + "role": "assistant", + "content": {"type": "text", "text": "I'll help debug that. What have you tried so far?"} + } + ], + "resultType": "complete" +} +``` + +Notice the last one. Pre-filling an `assistant` turn is how you steer the model's *next* reply without making the user type the steering themselves. + +## Titles and argument descriptions + +`review_code` is a function name, not a label. Give the client something better to put on the button, and describe each argument so the form explains itself: + +```python title="server.py" hl_lines="10-13" +--8<-- "docs_src/prompts/tutorial003.py" +``` + +* `title="Code review"` is the human-readable name, exactly like a tool's `title`. +* `Annotated[str, Field(description=...)]` is the same pattern **[Tools](tools.md)** uses to describe a tool's parameters. Here the description lands on the argument instead of in a schema. +* `language` has a default, so it stops being required. + +The `prompts/list` entry now carries everything a client needs to draw a good form: + +```json +{ + "name": "review_code", + "title": "Code review", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "description": "The code to review.", "required": true}, + {"name": "language", "description": "The language the code is written in.", "required": false} + ] +} +``` + +!!! info + If you have read **[Tools](tools.md)**, you already know everything on this page. Same decorator, same + docstring-as-description, same `Annotated`/`Field`. The only things that change are who + triggers it (the user) and where the result goes (into the conversation). + +## Recap + +* `@mcp.prompt()` on a function makes it a prompt. Name from the function, description from the docstring. +* Prompts are **user-controlled**: the client lists them, the user picks one and fills in the arguments. +* Arguments are a flat list of named strings (no schema). A parameter with a default is optional. +* Return a `str` and it becomes one user message. Return a list of `UserMessage` / `AssistantMessage` to seed a multi-turn conversation. +* `title=` and `Field(description=...)` are what a client puts in its UI. +* A missing required argument fails the whole request. There is no per-prompt error result. + +Server-side autocomplete for a prompt's (or a resource template's) arguments is **[Completions](completions.md)**. diff --git a/docs/servers/resources.md b/docs/servers/resources.md new file mode 100644 index 0000000..407f1d9 --- /dev/null +++ b/docs/servers/resources.md @@ -0,0 +1,141 @@ +# Resources + +A **resource** is data you expose for the application to read. + +That's the split. A tool is something the **model** decides to call. A resource is something the **application** decides to load (a config file, a record, a document) and put in front of the model as context. + +You declare one by putting `@mcp.resource(uri)` on a plain Python function. + +## Your first resource + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/resources/tutorial001.py" +``` + +It's the same shape as a tool, plus one thing: the **URI**. Resources are addressed, not named. A client asks for `config://app`, never for `get_config`. + +The SDK still reads the rest from the function: + +* The **name** is the function name: `get_config`. +* The **description** the client sees is the docstring. +* The **content** is whatever you return. + +During `resources/list` the client gets this: + +```json +{ + "name": "get_config", + "uri": "config://app", + "description": "The active shop configuration.", + "mimeType": "text/plain" +} +``` + +And when it reads `config://app`, your function runs and the return value comes back as text: + +```python +result.contents # [TextResourceContents(uri="config://app", mime_type="text/plain", text="theme=dark\nlanguage=en")] +``` + +!!! tip + Listing is cheap. Your function is **not** called during `resources/list`, only during + `resources/read`, and only for the URI that was asked for. Expose a thousand resources + and you pay for the ones somebody opens. + +### Try it + +Run the server with the MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Open the URL it prints and go to the **Resources** tab. `config://app` is in the list with its description. Click it and the Inspector reads it: there are your two lines of config. + +## Resource templates + +One URI per record doesn't scale. Put a **placeholder** in the URI and a matching parameter on the function: + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/resources/tutorial002.py" +``` + +`{user_id}` in the URI, `user_id: str` on the function. That is the entire contract. + +This is now a **resource template**, and it moves house: it leaves `resources/list` and shows up in `resources/templates/list` instead, as a pattern rather than an address: + +```json +{ + "name": "get_user_profile", + "uriTemplate": "users://{user_id}/profile", + "description": "A customer's profile.", + "mimeType": "text/plain" +} +``` + +The client fills in the placeholder and reads a concrete URI: `users://42/profile`, `users://ada/profile`. One function answers all of them, with the matched value passed in as `user_id`: + +```python +result.contents # [TextResourceContents(uri="users://42/profile", text="User 42: 12 orders since 2021.")] +``` + +Notice the `uri` in the result. It is the **concrete** URI the client asked for, not the template. + +!!! check + The placeholders and the parameters have to agree. Rename the function parameter to + `user` while the URI still says `{user_id}` and the decorator refuses **at import time**, + before any client gets near it: + + ```text + ValueError: Mismatch between URI parameters {'user_id'} and function parameters {'user'} + ``` + + A mismatch can only ever be a bug, so the SDK makes it impossible to start the server with one. + +The placeholder syntax is [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570): `{+path}` for multi-segment values, `{?q,lang}` for optional query parameters, and more. The SDK also applies path-safety checks to extracted values by default. See **[URI templates and path safety](uri-templates.md)** for the full reference. + +`get_user_profile` can also take a parameter annotated `Context`. The SDK injects it without ever treating it as a URI parameter, and **[The Context](../handlers/context.md)** page covers what it gives you. + +## What you return + +You're not limited to `str`. Give each resource a `mime_type` and return whatever fits: + +```python title="server.py" hl_lines="8-9 14-15 20-21" +--8<-- "docs_src/resources/tutorial003.py" +``` + +* `readme` returns a `str`, so it's sent as-is. This is the common case. +* `catalog_stats` returns a `dict`, so the SDK serialises it to **JSON text** for you: + + ```json + { + "books": 1204, + "authors": 391 + } + ``` + +* `placeholder_cover` returns `bytes`, so the client gets a `BlobResourceContents` instead of a `TextResourceContents`, with your bytes base64-encoded in its `blob` field. + +The same rule applies to anything else JSON-serialisable: a list, a Pydantic model, a dataclass. If it isn't a `str` and isn't `bytes`, it becomes JSON. + +`mime_type` is yours to declare, and it defaults to `text/plain`. The SDK never inspects what you return to guess it, so a `dict` resource you don't label is still advertised as plain text. + +!!! tip + `name=`, `title=` and `description=` are also accepted by `@mcp.resource()` when you don't + want to derive them from the function. And when there's no function to write at all, + `mcp.server.mcpserver.resources` has ready-made `Resource` classes (`TextResource`, + `BinaryResource`, `FileResource`, `HttpResource`, `DirectoryResource`) that you register + with `mcp.add_resource(...)`. + +A client can also **subscribe** to a resource and be notified when it changes; that's the client's half of the story and it lives in **[The Client](../client/index.md)**. + +## Recap + +* `@mcp.resource(uri)` on a function makes it a resource. The URI is the address, the return value is the content, the docstring is the description. +* A `{placeholder}` in the URI makes it a **template**: it's listed under `resources/templates/list` and one function serves every URI that matches. +* Placeholder names must equal the function's parameter names. Get it wrong and you find out at import time, not in production. +* Your function runs when the resource is **read**, not when it's listed. +* `str` becomes text, `bytes` becomes a base64 blob, anything else becomes JSON text. `mime_type=` is how you label it. +* Tools are for the model to act. Resources are for the application to read. + +The third primitive, the one a person picks from a menu, is **[Prompts](prompts.md)**. diff --git a/docs/servers/structured-output.md b/docs/servers/structured-output.md new file mode 100644 index 0000000..a146e01 --- /dev/null +++ b/docs/servers/structured-output.md @@ -0,0 +1,245 @@ +# Structured Output + +A tool that returns a plain `str` produces the result twice: as text in `content`, and as `{"result": "..."}` in `structured_content`. + +This page is about that second channel: where it comes from, every shape it can take, and how the SDK keeps it honest. + +The short version: **the return type annotation is the output schema**. You already wrote it. + +## The output schema + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial001.py" +``` + +The line that matters is the signature: `-> int`. + +Because of it, the tool the SDK sends during `tools/list` carries an `output_schema` next to the input schema it builds from your parameters (**[Tools](tools.md)** covers that one): + +```json +{ + "properties": { + "result": {"title": "Result", "type": "integer"} + }, + "required": ["result"], + "title": "get_temperatureOutput", + "type": "object" +} +``` + +A bare `int` isn't a JSON object, so the SDK **wraps** it in `{"result": ...}`. Call the tool and both channels are filled: + +```python +result.content # [TextContent(text="17")] +result.structured_content # {"result": 17} +``` + +Every scalar gets the same wrapper: `str`, `int`, `float`, `bool`, `bytes`, `None`. + +## Two channels + +Why send the same value twice? + +* `content` is for the **model**. A language model reads text; this is the only part of the result it sees. +* `structured_content` is for the **application** the model runs inside: code that wants `17`, not a sentence containing "17". +* `output_schema` is the contract between them, published before the tool is ever called. + +You return one Python value. The SDK fills in all three. + +## Return a model + +Declare the shape as a Pydantic `BaseModel` and return an instance: + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/structured_output/tutorial002.py" +``` + +`WeatherData` **is** the schema now. No wrapper, no `result` key: + +```json +{ + "properties": { + "temperature": {"description": "Degrees Celsius.", "title": "Temperature", "type": "number"}, + "humidity": {"description": "Relative humidity, 0 to 1.", "title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" +} +``` + +`structured_content` is the object, field for field: + +```python +result.structured_content # {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"} +``` + +And the model is not left out. The SDK serializes the same object to JSON text for `content`: + +```json +{ + "temperature": 16.2, + "humidity": 0.83, + "conditions": "Overcast" +} +``` + +Notice the `Field(description=...)` on `temperature` and `humidity` landed in the schema. The same `Field` that described your **inputs** describes your outputs. + +!!! info + If you've used FastAPI's `response_model`, you already know this: a Pydantic model as the declared + response, serialized and documented for you. The only difference is that here the return annotation + is the whole declaration. + +## A `TypedDict` + +Not every shape deserves a class. A `TypedDict` produces the same schema: + +```python title="server.py" hl_lines="8" +--8<-- "docs_src/structured_output/tutorial003.py" +``` + +A `TypedDict` is a plain `dict` at runtime, so that is what you build and return. The schema, the validation, and `structured_content` are identical to the `BaseModel` version (minus the descriptions, which `TypedDict` has no place for). + +## A dataclass + +Dataclasses work too, and so does any ordinary class whose attributes have type hints. The SDK builds a Pydantic model out of the annotations behind the scenes. + +```python title="server.py" hl_lines="8-9" +--8<-- "docs_src/structured_output/tutorial004.py" +``` + +Three spellings, one schema. Use whichever your codebase already has. + +## Lists + +A `list[...]` isn't a JSON object either, so it gets the `{"result": ...}` wrapper, with your item type as a `$defs` reference inside it: + +```python title="server.py" hl_lines="15" +--8<-- "docs_src/structured_output/tutorial005.py" +``` + +```json +{ + "$defs": { + "WeatherData": { + "properties": { + "temperature": {"title": "Temperature", "type": "number"}, + "humidity": {"title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" + } + }, + "properties": { + "result": {"items": {"$ref": "#/$defs/WeatherData"}, "title": "Result", "type": "array"} + }, + "required": ["result"], + "title": "get_forecastOutput", + "type": "object" +} +``` + +Ask for a two-day forecast and `structured_content` is `{"result": [{...}, {...}]}`. `content` becomes **two** `TextContent` blocks, one per item: a list is flattened for the model rather than dumped as one string. + +`tuple[...]`, unions, and `Optional[...]` are wrapped the same way. + +## Dictionaries + +`dict[str, ...]` is the one generic that already *is* a JSON object, so it isn't wrapped: + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial006.py" +``` + +```json +{ + "additionalProperties": {"type": "number"}, + "title": "get_temperaturesDictOutput", + "type": "object" +} +``` + +```python +result.structured_content # {"London": 16.2, "Reykjavik": 4.4} +``` + +The keys must be `str`. A `dict[int, float]` can't be a JSON object, so it falls back to the `{"result": ...}` wrapper. + +## Validation + +`output_schema` is not documentation. Whatever your function returns is **validated against it** before it leaves the server. + +You don't notice while you build the value by hand: Pydantic already made sure your `WeatherData` was a `WeatherData`. You notice the day the data comes from somewhere you don't control: + +```python title="server.py" hl_lines="9 21" +--8<-- "docs_src/structured_output/tutorial007.py" +``` + +The annotation promises `WeatherData`. The upstream response stopped sending `humidity`. + +!!! check + Call `get_weather` and it does not quietly hand the client a half-empty object. The call fails, + and the first lines of the error name the field: + + ```text + Error executing tool get_weather: 1 validation error for WeatherData + humidity + Field required [type=missing, input_value={'temperature': 16.2, 'conditions': 'Overcast'}, input_type=dict] + ``` + + That text comes back as the tool result with `is_error=True`, so the model knows the call failed + instead of confidently reading weather that isn't there. + +Returning a plain `dict` from a `-> WeatherData` tool is fine, by the way. That's exactly what `json.loads` produced. Validation is on the value, not on the Python type. + +## Opting out + +Sometimes the return annotation is for your type checker, not for the protocol. Pass `structured_output=False` and the tool is text-only: + +```python title="server.py" hl_lines="6" +--8<-- "docs_src/structured_output/tutorial008.py" +``` + +No `output_schema`, no wrapping, no validation. `structured_content` is `None` and `content` is the string you returned. + +The opposite, `structured_output=True`, turns the automatic detection into a requirement: a tool whose return type can't produce a schema raises at import time instead of falling back to text. + +## A class without type hints + +There is one way to end up unstructured without asking for it: return a class that has **no annotations on its body**. + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/structured_output/tutorial009.py" +``` + +`Station` sets `name` and `online` inside `__init__`, but the *class* declares nothing. The SDK reads class annotations, finds none, and gives up. + +!!! warning + It gives up **silently**. `output_schema` is `None`, `structured_content` is `None`, and the text + the model reads is the object's `repr`: + + ```text + "<server.Station object at 0x7f539d75b230>" + ``` + + No error, no warning, a useless tool. Move the annotations onto the class body, or pass + `structured_output=True`, which turns this into a hard error the moment the module imports: + `Function get_station: return type <class 'server.Station'> is not serializable for structured output`. + +!!! tip + Need full control (building the `CallToolResult` yourself, or attaching `_meta` that the + application can see but the model can't)? That's **[The low-level Server](../advanced/low-level-server.md)**. + +## Recap + +* The **return type annotation** is the output schema. It's published in `tools/list` as `output_schema`. +* Scalars, lists, tuples and unions are wrapped in `{"result": ...}`. Models, `TypedDict`s, dataclasses, annotated classes and `dict[str, ...]` are objects already and stay as they are. +* Every result carries `content` (text, for the model) **and** `structured_content` (data, for the application). +* What you return is validated against the schema. A mismatch is a tool error, not a corrupt result. +* `structured_output=False` opts a tool out. A class without type hints opts out silently; watch for it. + +You now own everything a tool can say back. Next, the second primitive: **[Resources](resources.md)**. diff --git a/docs/servers/tools.md b/docs/servers/tools.md new file mode 100644 index 0000000..8b7ee05 --- /dev/null +++ b/docs/servers/tools.md @@ -0,0 +1,172 @@ +# Tools + +A **tool** is a function the model can call. + +You declare one by putting `@mcp.tool()` on a plain Python function. That's the whole API. + +## Your first tool + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/tools/tutorial001.py" +``` + +Look at what you wrote. There are no schemas, no JSON, no protocol, just a function. The SDK reads three things from it: + +* The **name** of the tool is the name of the function: `search_books`. +* The **description** the model sees is the docstring: `Search the catalog by title or author.` +* The **arguments** the model is allowed to pass come from the type hints: `query: str` and `limit: int`. + +### The input schema + +From those type hints the SDK generates a JSON Schema and sends it to the client during `tools/list`: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"title": "Limit", "type": "integer"} + }, + "required": ["query", "limit"], + "title": "search_booksArguments" +} +``` + +Both arguments are in `required` because neither has a default. You'll fix that in a moment. (The `title` keys are Pydantic artifacts; the properties, their types, and `required` are the contract.) + +!!! tip + Type hints aren't documentation here. They are **the contract**. If a client sends `"limit": "ten"`, + the SDK rejects it before your function ever runs. + +### What the model gets back + +Call the tool with `{"query": "dune", "limit": 5}` and the result has two parts: + +```python +result.content # [TextContent(text="Found 3 books matching 'dune' (showing up to 5).")] +result.structured_content # {'result': "Found 3 books matching 'dune' (showing up to 5)."} +``` + +`content` is the text the **model** reads. `structured_content` is typed data for the **client application**. It's there because you declared the return type as `-> str`. + +Don't worry about `structured_content` yet. Return real Python objects from your tools and the right thing happens; the **[Structured Output](structured-output.md)** page is all about it. + +### Try it + +Run the server with the MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Open the URL it prints, go to the **Tools** tab, and call `search_books`. + +The Inspector renders a form with a required `query` text field and a required `limit` number field. It built that form from your type hints. So will every other MCP client. + +## Optional arguments + +Give a parameter a default value and it stops being required. That's it. It's just Python. + +```python title="server.py" hl_lines="7" +--8<-- "docs_src/tools/tutorial002.py" +``` + +The schema follows: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +`limit` left `required` and gained `"default": 10`. A client that omits it gets `10`, exactly as Python would. + +## Richer schemas with `Field` + +Type hints get you a long way, but sometimes you want to *describe* an argument, or constrain it. + +Wrap the type in `Annotated` and add a Pydantic `Field`: + +```python title="server.py" hl_lines="12-14" +--8<-- "docs_src/tools/tutorial003.py" +``` + +Three new things, all on the parameters: + +* `Field(description=...)`: a per-argument description the model reads alongside the docstring. +* `Field(ge=1, le=50)`: numeric bounds. They land in the schema as `"minimum": 1, "maximum": 50`. +* `Literal["fiction", "non-fiction", "poetry"]`: an enum. The model can only pick one of those. + +!!! check + Constraints are not decoration. Call the tool with `limit=999` and the SDK answers with a + tool error **before your function runs**: + + ```text + Input should be less than or equal to 50 + ``` + + That error goes back to the model as the tool result, and the model reads it and retries with + a valid value. You wrote `le=50` once and got self-correcting agents for free. + +!!! info + If you've used FastAPI or Pydantic, you already know all of this. It's the same `Field`, + the same `Annotated`, the same validation. There is nothing MCP-specific to learn here. + +## A model as a parameter + +When a tool takes more than a couple of arguments, group them into a Pydantic model: + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/tools/tutorial004.py" +``` + +The `Book` schema is nested inside the tool's input schema (as a `$defs` reference), the model fills it in as a JSON object, and your function receives a **real `Book` instance**, already validated, with `.title`, `.author` and `.year` attributes. + +You can mix and match: plain parameters next to model parameters, nested models, lists of models. It's Pydantic all the way down. + +## `async def` + +If a tool does I/O (calls an API, reads a file, queries a database), declare it `async def` and `await` inside it. The SDK awaits it. + +A plain `def` tool works too: the SDK runs it in a thread so it never blocks the server. + +There is nothing else to configure. + +## Names, titles, and annotations + +Everything the SDK infers, you can override in the decorator: + +```python title="server.py" hl_lines="8-11" +--8<-- "docs_src/tools/tutorial005.py" +``` + +* `title` is a human-readable name for UIs. Clients show *"Search the catalog"* instead of `search_books`. +* `annotations` are behavioural **hints** for the client: + * `read_only_hint=True`: this tool doesn't change anything. + * `open_world_hint=False`: it works on a closed set of things (this catalog), not the open web. + * The other two, `destructive_hint` and `idempotent_hint`, describe a tool that *writes*: may it + delete something, and is calling it twice the same as calling it once? The spec defines both + only for non-read-only tools, so they would say nothing on `search_books`. + +A well-behaved client uses them to decide things like *"do I need to ask the user before running this?"*. They are hints, not security. Never rely on a client honouring them. + +!!! tip + `name=` and `description=` are also accepted by `@mcp.tool()` if you don't want to derive them + from the function name and docstring. Most of the time you do. + +## Recap + +* `@mcp.tool()` on a function makes it a tool. Name from the function, description from the docstring. +* Type hints **are** the input schema. Defaults make arguments optional. +* `Annotated[..., Field(...)]` adds descriptions and constraints; `Literal` adds enums. +* A Pydantic model parameter is how you take a structured "body". +* Bad arguments are rejected for you, with an error the model can read and recover from. +* `async def` for I/O, plain `def` for everything else. + +**[Structured Output](structured-output.md)** is what happens to the value you `return`. diff --git a/docs/servers/uri-templates.md b/docs/servers/uri-templates.md new file mode 100644 index 0000000..6cda30e --- /dev/null +++ b/docs/servers/uri-templates.md @@ -0,0 +1,269 @@ +# URI templates and path safety + +This is the reference for the URI-template syntax that +[`@mcp.resource`](resources.md) accepts, and for the +path-safety policy the SDK applies to extracted values. For an +introduction to what resources are and when to use them, start with +**[Resources](resources.md)**; this page assumes you're already comfortable declaring a +resource and want the full operator set, the security knobs, or the +low-level wiring. + +The template syntax is [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570). +The SDK supports a subset chosen for matching incoming `resources/read` +URIs, plus a security layer that rejects values that would resolve +outside the directory you intend to serve. For the protocol-level +details (message formats, lifecycle, pagination) see the +[MCP resources specification](https://modelcontextprotocol.io/specification/latest/server/resources). + +## The full operator set + +The plain placeholder, `{user_id}`, is the one **[Resources](resources.md)** introduces. There are four more +operator forms; here they are on one server so you can see them next to +each other: + +```python title="server.py" hl_lines="16-17 22-23 28-29 34-35 40-41" +--8<-- "docs_src/uri_templates/tutorial001.py" +``` + +Each highlighted decorator is a different way of carving up the URI. +The sections below walk them top to bottom. + +### Simple expansion: `{name}` + +`books://{isbn}` is the plain, everyday form. The placeholder maps to +the `isbn` parameter, so a client reading `books://978-0441172719` calls +`get_book("978-0441172719")`. + +A plain `{name}` stops at the first `/`. `books://978/extra` does not +match because the slash after `978` ends the capture and `/extra` is +left over. + +### Type conversion + +Extracted values arrive as strings, but you can declare a more specific +type and the SDK will convert. `orders://{order_id}` lands in a function +whose parameter is `order_id: int`, so reading `orders://12345` calls +`get_order(12345)`, not `get_order("12345")`. The handler does +arithmetic on it (`order_id + 1`) without a cast. + +### Multi-segment paths: `{+name}` + +To capture a value that contains slashes, use `{+name}`. With +`manuals://{+path}`: + +* `manuals://returns.md` gives `path = "returns.md"` +* `manuals://printing/setup.md` gives `path = "printing/setup.md"` + +Reach for `{+name}` whenever the value is hierarchical: filesystem +paths, nested object keys, URL paths you're proxying. + +### Query parameters: `{?a,b,c}` + +`reviews://{isbn}{?limit,sort}` puts `limit` and `sort` after the `?`. +The path identifies *which* book; the query tunes *how* you read it. + +Query params are matched leniently: order doesn't matter, extras are +ignored, and omitted params fall through to your function defaults. So +`reviews://978-0441172719` uses `limit=10, sort="newest"`, and +`reviews://978-0441172719?sort=top` overrides only `sort`. + +### Path segments as a list: `{/name*}` + +If you want each path segment as a separate list item rather than one +string with slashes, use `{/name*}`. With `shelves://browse{/path*}`, a +client reading `shelves://browse/fiction/sci-fi` calls +`browse_shelf(["fiction", "sci-fi"])`. + +### Template reference + +The most common patterns: + +| Pattern | Example input | You get | +|--------------|-----------------------|-------------------------| +| `{name}` | `alice` | `"alice"` | +| `{name}` | `docs/intro.md` | *no match* (stops at `/`) | +| `{+path}` | `docs/intro.md` | `"docs/intro.md"` | +| `{.ext}` | `.json` | `"json"` | +| `{/segment}` | `/v2` | `"v2"` | +| `{?key}` | `?key=value` | `"value"` | +| `{?a,b}` | `?a=1&b=2` | `"1"`, `"2"` | +| `{/path*}` | `/a/b/c` | `["a", "b", "c"]` | + +### What the parser rejects + +A few template shapes are caught up front rather than failing on the +first request. `@mcp.resource` parses the template when the decorator +runs, so none of these ever reach a running server. + +`UriTemplate.parse()` raises `InvalidUriTemplate` for: + +* **Two variables with nothing between them.** `manuals://{+path}{ext}` + is rejected: matching can't tell where `path` ends and `ext` begins. + Put a literal between them (`manuals://{+path}/{ext}`), or use an + operator that supplies its own delimiter. `manuals://{+path}{.ext}` + is accepted because `{.ext}` contributes the `.` itself. +* **More than one multi-segment variable.** At most one of `{+var}`, + `{#var}`, or an exploded variable (`{/var*}`, `{.var*}`, `{;var*}`) + per template. Two are inherently ambiguous: there is no principled + way to decide which one absorbs an extra segment. +* **The usual syntax errors**: an unclosed brace, a variable name used + twice, or an RFC 6570 feature the SDK doesn't support, such as the + `{var:3}` prefix modifier or the `{?vars*}` query explode. + +On top of that, `@mcp.resource` raises `ValueError` when a handler +parameter is bound to a query variable in the template's trailing +`{?...}`/`{&...}` run but has no Python default. Those variables are +matched leniently (a client may leave any of them out), so a parameter +without a default would only surface as an opaque internal error on the +first request that omits it. `reviews://{isbn}{?limit,sort}` in the +server above is the well-formed version: `limit` and `sort` both carry +defaults. + +## Security + +Template parameters come from the client. If they flow into filesystem +or database operations unchecked, values like `../../etc/passwd` can +resolve outside the directory you intended to serve. + +### What the SDK checks by default + +Before your handler runs, the SDK rejects any parameter that: + +* would escape its starting directory via `..` components +* looks like an absolute path (`/etc/passwd`, `C:\Windows`) or a + Windows drive-relative one (`C:foo`). A drive-relative value and a + namespaced identifier like `x:y` are indistinguishable as strings, + so any single-letter-plus-colon value is rejected by default; + exempt the parameter if it legitimately receives such values +* contains a null byte (`\x00`) + +The `..` check is component-based, not a substring scan. Values like +`v1.0..v2.0` or `HEAD~3..HEAD` pass because `..` is not a standalone +path segment there. + +These checks apply to the decoded value, so they catch traversal +regardless of how it was encoded in the URI (`../etc`, `..%2Fetc`, +`%2E%2E/etc`, `..%5Cetc`, `%00` all get caught). + +!!! check + Read `manuals://../etc/passwd` from the server above and the request + is rejected outright: template matching stops at the first failure, + so no later (potentially more permissive) template is tried as a + fallback. The client sees the same `-32602` "Unknown resource" error + it would for a URI that matches no template at all, and + `read_manual` never runs. + +### Filesystem handlers: use safe_join + +The built-in checks stop the common cases but can't know your sandbox +boundary. For filesystem access, use `safe_join` to resolve the path +and verify it stays inside your base directory: + +```python title="server.py" hl_lines="4 14" +--8<-- "docs_src/uri_templates/tutorial002.py" +``` + +`safe_join` catches symlink escapes, `..` sequences, and absolute-path +tricks that a simple string check would miss. If the resolved path +escapes `DOCS_ROOT`, it raises `PathEscapeError`, which surfaces to the +client as a `ResourceError`. + +### When the defaults get in the way + +Sometimes the checks block legitimate values. A catalog-import tool +might intentionally receive an absolute path, or a parameter might be a +relative reference like `../sibling` that your handler interprets +safely without touching the filesystem. Exempt that parameter, or relax +the policy for the whole server: + +```python title="server.py" hl_lines="9 16-19" +--8<-- "docs_src/uri_templates/tutorial003.py" +``` + +* `security=ResourceSecurity(exempt_params={"source"})` on the decorator + skips the checks for that one parameter on that one resource. The + rest of the server keeps the default policy. +* `resource_security=` on the `MCPServer` constructor sets the default + for every resource. Here `relaxed` turns off the `..` check entirely. + +The configurable checks: + +| Setting | Default | What it does | +|-------------------------|---------|-------------------------------------| +| `reject_path_traversal` | `True` | Rejects `..` sequences that escape the starting directory | +| `reject_absolute_paths` | `True` | Rejects `/foo`, `C:\foo`, UNC paths, and drive-relative `C:foo` (also catches `x:y`) | +| `reject_null_bytes` | `True` | Rejects values containing `\x00` | +| `exempt_params` | empty | Parameter names to skip checks for | + +These checks are a heuristic pre-filter; for filesystem access, +`safe_join` remains the containment boundary. + +!!! tip + If your handler can't fulfil the request (the file doesn't exist, + the id is unknown), raise an exception. The SDK turns it into an + error response. See **[Handling errors](handling-errors.md)** for the difference between a + protocol error and a tool error. + +## Resources on the low-level Server + +If you're building on the low-level `Server` (see **[The low-level +Server](../advanced/low-level-server.md)**), you register handlers for the `resources/list` and +`resources/read` protocol methods directly. There's no decorator; you +return the protocol types yourself. + +### Static resources + +For fixed URIs, keep a registry and dispatch on exact match: + +```python title="server.py" hl_lines="18 22 28" +--8<-- "docs_src/uri_templates/tutorial004.py" +``` + +The list handler tells clients what's available; the read handler +serves the content. Check your registry first, fall through to +templates (below) if you have any, then raise for anything else. + +### Templates + +The template engine `MCPServer` uses lives in `mcp.shared.uri_template` +and works on its own. You get the same parsing and matching; you wire +up the routing and security policy yourself. + +```python title="server.py" hl_lines="14-17 23-26 30 34 46" +--8<-- "docs_src/uri_templates/tutorial005.py" +``` + +Three things are happening in the highlighted lines: + +* **Parse once, match per request.** `UriTemplate.parse()` builds the + template; `template.match(uri)` returns the extracted variables as a + `dict`, or `None` if the URI doesn't fit. URL decoding happens inside + `match()`; the decoded values are returned as-is without path-safety + validation. Values come out as strings: convert them yourself + (`int(matched["id"])`, `Path(matched["path"])`). +* **Apply the safety checks yourself.** The `..` and absolute-path + checks `MCPServer` runs by default live in `mcp.shared.path_security`. + `read_manual_safely` calls them before touching `MANUALS`. If a + parameter isn't a filesystem path (an ISBN, a search query), skip the + checks for that value: you control the policy per handler rather than + through a config object. +* **List the templates from the same source.** Clients discover + templates through `resources/templates/list`. `str(template)` gives + back the original template string, so the listing and the matcher + share one source of truth. + +## Recap + +* `{name}` matches one segment; `{+name}` keeps the slashes; `{?a,b}` + pulls from the query string; `{/name*}` splits segments into a list. +* Two variables with nothing between them, or a second multi-segment + variable, are rejected at parse time. A parameter bound to a trailing + `{?...}`/`{&...}` query variable must declare a Python default. +* Annotate the parameter (`order_id: int`) and the SDK converts. +* The default security policy rejects `..`, absolute paths, and null + bytes before your handler runs; override per resource with + `security=ResourceSecurity(...)` or server-wide with + `resource_security=`. +* For filesystem access, `safe_join` is the containment boundary. +* On the low-level `Server`, parse with `UriTemplate.parse()`, match + with `.match()`, and apply `mcp.shared.path_security` yourself. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..621b32c --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,412 @@ +# Troubleshooting + +Every heading on this page is the exact text of an error the SDK produces, followed by what it means and the one-move fix. Find the last line of your traceback (or your server log) here with your browser's find-in-page, and read only that entry. + +Several entries run against this one server. One tool and one templated resource, each raising for a city it doesn't know: + +```python title="server.py" +--8<-- "docs_src/troubleshooting/tutorial001.py" +``` + +The errors this page quotes are real: the SDK's own test suite reproduces every one of them. + +## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` + +This is not an MCP error. It is anyio noise, and your real error is the **last line** of the paste. + +`Client.__aenter__` starts a task group. anyio wraps anything that leaves a task group in an `ExceptionGroup`, so *every* exception that escapes an `async with Client(...)` block, whatever it is, arrives inside one: + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.read_resource("weather://Atlantis") +``` + +```text + + Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Traceback (most recent call last): + | ... + | mcp.shared.exceptions.MCPError: No forecast for 'Atlantis'. + +------------------------------------ +``` + +Two things to do with that: + +1. **Read the bottom.** `MCPError: No forecast for 'Atlantis'.` is the failure; find *its* text on this page. +2. **Catch inside the block.** The `ExceptionGroup` only appears when the exception *leaves* the `async with`. Caught inside it, the same failure is the plain `MCPError`, no group anywhere: + +```python +async def main() -> None: + async with Client(mcp) as client: + try: + await client.read_resource("weather://Atlantis") + except MCPError as e: + print(e) # No forecast for 'Atlantis'. +``` + +!!! tip + A failure during *connection* (a wrong URL, a server that isn't running, the `421` further + down this page) escapes from `async with` itself, so there is no "inside" to catch it in. + For those, read the bottom of the group. + +## `RuntimeError: Client must be used within an async context manager` + +`Client(...)` only builds the object. Nothing connects until `async with`, so every method refuses: + +```python +async def main() -> None: + client = Client(mcp) + tools = await client.list_tools() # RuntimeError +``` + +Enter it. `__aenter__` is the connection: + +```python +async def main() -> None: + async with Client(mcp) as client: + tools = await client.list_tools() +``` + +`__aexit__` is the disconnection, which is why there is no `client.close()` to forget. **[Testing](get-started/testing.md)** is built on exactly this pattern. + +## `Error executing tool <name>: <message>` and `Unknown tool: <name>` + +You are reading a **result**, not an exception. `call_tool` did not raise, and it never will for a failing tool. + +Call `forecast` for a city the server doesn't know, and the exception it raises comes back with the request marked as *succeeded*: + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool forecast: No forecast for 'Atlantis'.")] +result.structured_content # None +``` + +`Unknown tool: get_forecast` is the same shape for a name the server never registered, and a bad argument is rejected the same way, against the tool's input schema, before your function ever runs. + +The fix is in your client: **check `result.is_error`**. A `try/except` around `call_tool` catches none of these, because there is nothing to catch. This is deliberate, and it is the single most useful thing on this page to internalise: the *model* chose the call, so the model gets the message and a chance to try again. **[Handling errors](servers/handling-errors.md)** is the whole story, including the `MCPError` path that *does* raise. + +## `TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool` + +You wrote `@mcp.tool` instead of `@mcp.tool()`. `tool()` is a decorator *factory*: without the parentheses, Python hands your function to its `name=` parameter. + +```python +@mcp.tool # <- missing () +def forecast(city: str) -> str: + """Today's forecast for one city.""" + return f"{city}: Rain." +``` + +```text +TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool +``` + +Add the parentheses. `@mcp.resource(...)` and `@mcp.prompt()` say the same thing for the same slip. + +!!! note + This raises when the module is **imported**, before any client connects. So a host that shows + your server as *failed to start* (or *disconnected*), rather than as connected with zero + tools, has this shape: run `python server.py` yourself and read the traceback. A type checker + also catches it: a function is not a valid `name=`. + +## `Tool already exists: <name>` + +Two registrations used the same tool name. The **first** one wins, the second is silently dropped, and this warning in the *server log* is the only signal: + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/troubleshooting/tutorial002.py" +``` + +```text +WARNING mcp.server.mcpserver.tools.tool_manager: Tool already exists: forecast +``` + +`tools/list` reports one `forecast`, and it is `forecast_today`. Rename one of them. `MCPServer(..., warn_on_duplicate_tools=False)` silences the warning without changing the outcome, so leave it on. Resources and prompts have the same rule and the same log line (`Resource already exists:`, `Prompt already exists:`). + +## My host lists zero tools + +There is no error string for this, which is exactly why it is hard to search. The SDK never drops a registered tool from `tools/list`, so work outward: + +* **Did the server start at all?** `@mcp.tool` without parentheses raises at import time, and a crashed server looks a lot like an empty one in some hosts. Run `python server.py` yourself. +* **Is the tool on the `mcp` the host is running?** A second `MCPServer(...)` in another module is a different, empty server. Check which object the host's command actually imports. +* **Did two tools share a name?** Then one of them is gone. Look for `Tool already exists:` in the server log. +* **Is the host's list stale?** Adding a tool after startup only reaches clients that handle `notifications/tools/list_changed`. Restarting the host is the blunt fix. +* **Did something write to `stdout`?** On a stdio transport, stdout *is* the protocol: one stray `print()` and the host drops the connection, which some hosts render as a server with nothing in it. Log with the `logging` module instead. The rest of the host-side checklist is on **[Connect to a real host](get-started/real-host.md)**. + +An "invalid" tool name is *not* on that list: a non-conforming name logs a warning but the tool is registered and listed anyway. + +## `MCPError: Server returned an error response` + +The server refused the HTTP request outright, with a body that is not JSON-RPC, so the python `Client` has nothing better to show you than this stand-in. + +By far the most common cause is a freshly deployed Streamable HTTP server. `streamable_http_app()` (and `mcp.run("streamable-http")`) with no `transport_security=` defaults to **DNS-rebinding protection**: it accepts only requests whose `Host` header is localhost. That is the right default on your laptop and the wrong one behind a real hostname: + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/troubleshooting/tutorial003.py" +``` + +Deploy that, point a client at it, and the connection fails on the handshake: + +```python +async with Client("https://mcp.example.com/mcp") as client: + ... +``` + +```text +mcp.shared.exceptions.MCPError: Server returned an error response +``` + +The words the server actually sent, `421` and `Invalid Host header`, never reach you: the 421 body has no `Content-Type: application/json`, so the client cannot parse it. They are in the **server's log**, which is where to look next: + +```text +WARNING mcp.server.transport_security: Invalid Host header: mcp.example.com +``` + +The fix is `transport_security=`. Allowlist the hostname you actually serve: + +```python title="server.py" hl_lines="14-17" +--8<-- "docs_src/troubleshooting/tutorial004.py" +``` + +!!! check + That is the whole change. The identical client now connects, negotiates `2026-07-28`, and + calls `forecast`. + +**[Deploy & scale](run/deploy.md)** covers what each field means, the reverse-proxy case, and everything else that changes at deploy time. And `421 Misdirected Request` / `Invalid Host header`, right below, is the same failure seen from the other side. + +## `421 Misdirected Request` / `Invalid Host header` + +This is `Server returned an error response`, seen from anything that is *not* the python `Client`: curl, a browser's network tab, a reverse proxy's access log, or another SDK. + +```bash +curl -i https://mcp.example.com/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' +``` + +```text +HTTP/1.1 421 Misdirected Request + +Invalid Host header +``` + +`421 Misdirected Request` is HTTP's own reason phrase for the status; `Invalid Host header` is the SDK's response body; and the python `Client` renders the same event as `Server returned an error response`. All three are one refusal. The check runs against the **`Host` header the request carries**, not the address the server bound, so a reverse proxy that forwards the public hostname trips it exactly as a direct client does. + +The fix is the same `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` shown under `Server returned an error response`. Two of its edges are worth naming: + +* An `allowed_hosts` entry is an exact string. `"mcp.example.com"` matches a bare `Host` header and `"mcp.example.com:*"` matches any explicit port. List both. +* A `403` with the body `Invalid Origin header` is the sibling check on the `Origin` header. It only fires for browsers (nothing else sends `Origin`), and `allowed_origins=` is its allowlist. + +**[Deploy & scale](run/deploy.md)** has the full treatment, including when switching the check off is the honest configuration. + +## `RuntimeError: Task group is not initialized. Make sure to use run().` + +Your MCP app is mounted inside another ASGI app, and nothing started its **session manager**. + +`mcp.streamable_http_app()` returns a Starlette app whose own lifespan starts the manager, and `uvicorn server:app` runs that lifespan for you. But Starlette **never runs a mounted sub-application's lifespan**, so the moment the app goes inside a `Mount`, the manager never starts and the first request explodes: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial005.py" +``` + +The server starts. The route resolves. Then `uvicorn` prints this for every request: + +```text +ERROR: Exception in ASGI application +Traceback (most recent call last): + ... +RuntimeError: Task group is not initialized. Make sure to use run(). +``` + +The client sees a 500. The fix is a lifespan on the **host** app that enters `mcp.session_manager.run()`: + +```python +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with mcp.session_manager.run(): + yield + + +app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lifespan) +``` + +**[Add to an existing app](run/asgi.md)** is the page for this, including several servers in one app and FastAPI. Two neighbouring strings from the same class: + +* `StreamableHTTPSessionManager .run() can only be called once per instance. Create a new instance if you need to run again.` The manager is single-use; entering the same app's lifespan twice hits it. +* `mcp.session_manager` only exists **after** `streamable_http_app()` has been called, so build the routes first and touch the manager only inside the lifespan. + +## `MCPError: Session not found` + +The server does not recognise the `Mcp-Session-Id` your client sent, almost always because the server **restarted** (or you were routed to a different instance). Sessions live in that one process's memory. + +There is no server bug to find. The HTTP response is a `404` whose body *is* JSON-RPC, so, unlike the `421` above, the python `Client` shows you this one verbatim: + +```json +{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Session not found"}} +``` + +The fix is to reconnect: leave the `async with Client(...)` block and enter a new one, which negotiates a fresh session. For a long-lived client, that means catching `MCPError` around your calls and reconnecting on this message rather than retrying inside a dead session. + +If it happens *without* a restart, you are running more than one worker without sticky sessions: each worker holds its own session table, so a request routed to the wrong one lands here. **[Deploy & scale](run/deploy.md)** and **[Serving legacy clients](run/legacy-clients.md)** own that story and its two fixes (sticky routing, or `stateless_http=True`). + +For the server operator, the matching log line is `Rejected request with unknown or expired session ID: <id>`. It is logged at `INFO`, so it is invisible at the usual `WARNING` threshold. Seeing it in bursts right after a deploy is normal; every connected client is reconnecting. + +## `MCPError: Method not found` + +One side sent a JSON-RPC request the other has no handler for, and `e.error.data` names the method. The usual cause is an **era mismatch**: a method that exists in one protocol revision and not in the other, sent to a peer on the wrong one, such as a `2025`-era `resources/subscribe` arriving at a `2026-07-28` connection, or a `2026`-only `subscriptions/listen` sent by a client pinned to `mode="legacy"`. **[Protocol versions](protocol-versions.md)** is the map of which side speaks what, and the other honest cause (an optional capability you never registered a handler for) is on **[Completions](servers/completions.md)**. + +One thing does **not** produce this error, despite being a request the modern protocol removed: a tool calling `ctx.elicit()` on a `2026-07-28` connection. The server refuses to *send* that request at all, so what you get instead is `Cannot send 'elicitation/create': ...`, further down this page. + +## `MCPError: Client did not declare the form elicitation capability required by resolver '<name>'` + +Your server wants to ask the user something, and this client never said it can be asked. + +An elicitation resolver refuses up front when the connected client did not declare form elicitation, and `e.error.data` names exactly what is missing: + +```json +{ + "code": -32021, + "message": "Client did not declare the form elicitation capability required by resolver 'server:ask_to_confirm'", + "data": {"requiredCapabilities": {"elicitation": {"form": {}}}} +} +``` + +Pass `elicitation_callback=` to `Client(...)`. Registering the callback *is* the capability declaration; there is no second switch: + +```python +async def main() -> None: + async with Client(mcp, elicitation_callback=handle_elicitation) as client: + result = await client.call_tool("book_table", {"date": "Friday"}) +``` + +**[Client callbacks](client/callbacks.md)** lists the others (`sampling_callback`, `list_roots_callback`), each of which is a declaration in the same way. + +!!! info + `-32021` is `MISSING_REQUIRED_CLIENT_CAPABILITY`, one of three error codes the 2026-07-28 + spec adds. None of them is an exception class: they all arrive as `MCPError`, and + `e.error.code` is where to look. `mcp_types` exports the constants. The other two are + `-32020` `HEADER_MISMATCH` (an HTTP header disagrees with the request body it accompanies) + and `-32022` `UNSUPPORTED_PROTOCOL_VERSION` (the request named a version this server does not + speak). A conforming SDK client cannot produce either, so if you see one, look at whatever is + rewriting requests between your client and your server. + +## `MCPError: Elicitation not supported` + +The same gap as `Client did not declare the form elicitation capability ...`, spelled by the paths that don't check up front: the server needed an elicitation answered, and the connected client registered no `elicitation_callback`. + +You see this one from `ctx.elicit()` on a legacy connection, and on any connection at all from a returned multi-round-trip question (**[Multi-round-trip requests](handlers/multi-round-trip.md)**) that reaches a client with no callback to answer it. The fix is identical: pass `elicitation_callback=` to `Client(...)`. There is no version of "the user wasn't asked" that your tool receives as a `decline`; a client that cannot be asked is a failed call, so design your tools for it. + +## `MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.` + +Your handler tried to reach the client mid-request, on a connection where nothing can carry a request from the server. There are exactly two ways to be on one. + +**A `2026-07-28` connection: any transport, always.** The modern protocol has no server-initiated requests at all, so the server refuses before anything is sent. `ctx.elicit()` inside a tool is the classic way to meet this (on the very first in-memory test, since `Client(server)` negotiates `2026-07-28` without being asked), and passing `elicitation_callback=` changes nothing, because no request ever reaches the client for it to answer: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial006.py" +``` + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("book_table", {"date": "Friday"}) +``` + +```text +mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests. +``` + +**A legacy connection on a `stateless_http=True` server.** Statelessness means every request is its own world: no session, no server-to-client stream, and so nowhere to send an `elicitation/create` (or `sampling/createMessage`, or `roots/list`) even for the era that has them: + +```python title="server.py" hl_lines="16 23" +--8<-- "docs_src/troubleshooting/tutorial008.py" +``` + +The message names the method it could not send. `NoBackChannelError` is the class the server raises, but the wire carries only the base `MCPError`, so the sentence above is your traceback's last line, not the class name. + +The fix is the same for both: don't reach back mid-call. Move the question into a **resolver** (or return an `InputRequiredResult` yourself) and it becomes part of the *response*, which every connection can carry: + +```python title="server.py" hl_lines="15-17 21" +--8<-- "docs_src/troubleshooting/tutorial007.py" +``` + +Same question, same `elicitation_callback` on the client. The difference is under the hood: a resolver lets the server *return* the question from the call instead of pushing it, so nothing ever flows server-to-client. **[Elicitation](handlers/elicitation.md)** covers resolvers; **[Multi-round-trip requests](handlers/multi-round-trip.md)** covers what happens on the wire. + +!!! check + The tool with `ctx.elicit()` is not wrong, it is *pre-2026*. Connect with `mode="legacy"` + (the classic `initialize` handshake, spec `2025-11-25` and earlier) to a server that is not + `stateless_http=True`, and it works, because the server-to-client channel exists there. + **[Protocol versions](protocol-versions.md)** is the page on what each version has. + +## `MCPError: Invalid or expired requestState` + +The server could not verify the `requestState` token your client echoed back, so it refused the round. + +`requestState` is the opaque resume token a **[multi-round-trip](handlers/multi-round-trip.md)** call carries between legs. `MCPServer` seals it on the way out and verifies every echo, and it verifies *every* inbound `request_state` on `tools/call`, `prompts/get`, and `resources/read`, even for a handler that never mints one. So a token this process didn't seal is refused wherever it lands: + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") +``` + +```text +mcp.shared.exceptions.MCPError: Invalid or expired requestState +``` + +The message is deliberately frozen: the wire never reveals which check failed. The reason goes to the **server log**, and reading it is the whole diagnosis: + +```text +WARNING mcp.server.request_state: requestState rejected on tools/call: malformed +``` + +The reasons you will actually see: + +* **`unknown key`** is the one that matters. The default sealing key is generated at process start, so a retry that lands on a **different worker**, a different instance behind a load balancer, or the same server **after a restart** was sealed under a key this process never had. That is not an attacker; it is the default meeting more than one process. +* **`audience`**: the token was sealed by an instance with a *different server name*. The name is the seal's default audience claim, so a fleet must share the name (or set an explicit `RequestStateSecurity(audience=...)`) as well as the keys. +* **`expired`**: the round took longer than the seal's `ttl`, which is 600 seconds and per round, not per call. +* **`malformed`** / **`codec error`**: the token was altered in transit, or was never a sealed token at all. +* **`request binding`**: the token came back with a different tool, different arguments, or a different method. + +The multi-process fix is one argument (the *same* `keys` on every instance) plus one thing that is not an argument at all: the same server *name* (or an explicit shared `audience=`). + +```python +mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key])) +``` + +`keys[0]` seals; every key in the list verifies, which is what makes zero-downtime rotation possible. **[Multi-round-trip requests](handlers/multi-round-trip.md#protecting-requeststate)** explains what the seal protects and the rotation sequence, and **[Deploy & scale](run/deploy.md)** walks the whole two-worker failure and its two-part fix. + +!!! tip + `keys=[...]` refuses a weak key immediately, with an unusually helpful message: + + ```text + ValueError: request-state keys must be at least 32 bytes of secret randomness; keys[0] is 7 bytes. Generate one with: python -c "import secrets; print(secrets.token_hex(32))" + ``` + + Do what it says. + +## Still stuck? + +* If a message the SDK produced is not on this page, that is a documentation bug worth reporting on its own. +* Search the [issue tracker](https://github.com/modelcontextprotocol/python-sdk/issues); most error strings appearing there are already someone's write-up. +* Found nothing? [Open an issue](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml) with the full traceback, or ask in [#python-sdk-dev on the MCP Contributors Discord](https://discord.gg/6CSzBmMkjX). + +## Recap + +* `ExceptionGroup: unhandled errors in a TaskGroup` is never the error. Read the **last line**; catching `MCPError` *inside* the `async with Client(...)` block skips the wrapping entirely. +* `call_tool` does not raise for a failing tool. `Error executing tool ...` and `Unknown tool: ...` are results: check `result.is_error`. +* `Client must be used within an async context manager` -> use `async with`. `Use @tool() instead of @tool` -> add the parentheses. +* `Tool already exists:` in the server log is the only sign that two same-named tools collapsed into one. +* One 421, three spellings: `Server returned an error response` (the python `Client`), `421 Misdirected Request` / `Invalid Host header` (everything else), `Invalid Host header: <host>` (the server log). Fix: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`. +* `Task group is not initialized` -> a mounted app whose host lifespan never entered `mcp.session_manager.run()`. +* `Session not found` -> the server restarted; reconnect. +* `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` needs a server-to-client channel: a `2026-07-28` connection never has one, and `stateless_http=True` takes away the legacy one. Use a resolver. Its neighbour `Method not found` is a request for a method the other side's protocol revision doesn't have. +* `Client did not declare the form elicitation capability ...` and `Elicitation not supported` -> the client is missing `elicitation_callback=`. +* `Invalid or expired requestState` never says why on the wire. The server log does; `unknown key` means share `RequestStateSecurity(keys=[...])` across workers. diff --git a/docs/whats-new.md b/docs/whats-new.md new file mode 100644 index 0000000..ff9cfde --- /dev/null +++ b/docs/whats-new.md @@ -0,0 +1,210 @@ +# What's new in v2 + +Two things happened at once in v2. The **SDK was rebuilt**: a new engine under both the client and the server, a first-class `Client`, and a set of renames that a v1 codebase meets on its first import. And the **protocol moved**: v2 speaks the 2026-07-28 revision of MCP, which removes the connection handshake, the session, and every server-initiated request, without stranding the clients you already have. + +This page is the tour of both halves, one section per headline, each ending in the page that owns the topic. It is not the porting manual. That is the **[Migration Guide](migration.md)**: every breaking change, with before and after code. + +!!! note "v2 is a beta" + `pip install mcp` still installs v1.x: you opt into v2 with an exact version pin, and the + API can still move before the stable release, which lands alongside the spec release. + **[Installation](get-started/installation.md)** has the copy-paste install line and the + pinning rules. And if anything in v2 breaks, surprises, or slows you down, + [tell us](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml): + while v2 is in beta, that is the most useful thing you can send us. + +## The SDK: v1 to v2 + +### `FastMCP` is now `MCPServer` + +The high-level server class was renamed, and its module with it. This is the first thing every v1 server hits, because the old import path is gone rather than deprecated: + +```python +from mcp.server import MCPServer # v1: from mcp.server.fastmcp import FastMCP + +mcp = MCPServer("Demo") # v1: FastMCP("Demo") +``` + +It is also, for a decorator-built server, most of the port. `@mcp.tool()`, `@mcp.resource()`, and `@mcp.prompt()` accept what they accepted in v1 (`@mcp.resource()` adds one optional `security=` keyword), and the input schema still comes from your type hints. Around the edges: everything under `mcp.server.fastmcp.*` now lives under `mcp.server.mcpserver.*`, `ctx.fastmcp` is `ctx.mcp_server`, `get_context()` is gone (declare a `ctx: Context` parameter instead), and the exception base `FastMCPError` is `MCPServerError`. The **[Migration Guide](migration.md#fastmcp-renamed-to-mcpserver)** has the import table. + +### `Resolve`: the new way to ask the user for input + +Not everything a tool needs should come from the model. New in v2, a tool parameter annotated with `Resolve(fn)` is filled by a function you write instead, invisibly to the model, and that function can return `Elicit(...)` to put a question in front of the user. This is the preferred way to get anything from the client mid-call: the SDK carries the question over whichever mechanism the connection supports (a live elicitation request for a legacy client, a multi-round-trip on 2026-07-28), so one tool body serves both eras. **[Dependencies](handlers/dependencies.md)** is the page. + +!!! note + The other two forms remain when you need them: `ctx.elicit()` still works for clients on + legacy connections (**[Elicitation](handlers/elicitation.md)**), and a handler can return an + `InputRequiredResult` itself and drive the rounds by hand, which is also how sampling and + roots requests travel at 2026-07-28 (**[Multi-round-trip requests](handlers/multi-round-trip.md)**). + +### A first-class `Client` + +v1 handed you three nested layers: a transport context manager yielding raw streams, a `ClientSession` wrapped around them, and a hand-called `await session.initialize()`. v2 has one object: + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +`Client` takes a server object (in memory, no transport: the testing story), a URL (Streamable HTTP), or any transport context manager such as `stdio_client(...)`. Entering `async with` connects and negotiates the protocol version, whichever era the server speaks; `client.server_info`, `client.server_capabilities`, and `client.protocol_version` are simply there afterwards. The sampling and elicitation callbacks you registered in v1 still work (their bodies see the same snake_case attribute rename as everything else on this page), they now also answer the 2026-style requests-inside-results (below), and they run concurrently instead of one at a time. `ClientSession` is still underneath for anyone who wants the low-level surface, and `client.session` hands it to you; it moved too (it runs on the new dispatcher engine, and some of its own signatures changed), so read the **[Migration Guide](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** before you drop down. + +**[The Client](client/index.md)** introduces it, **[Client transports](client/transports.md)** covers the three connection forms, **[Client callbacks](client/callbacks.md)** covers the callbacks themselves, and **[Testing](get-started/testing.md)** shows the in-memory pattern that replaces v1's `create_connected_server_and_client_session()` helper. + +### The low-level `Server` was rebuilt, not renamed + +If you work at the JSON-RPC layer, this is the "everything is different" part of v2. Here is the same one-tool server both ways; click the markers for what moved. + +<!-- The v1 fence cannot be a tested docs_src file (nothing in CI can import the +1.x SDK). Its ground truth: this exact code was run verbatim against a real +mcp==1.28.1 install. If you edit it, re-validate it against 1.x. --> + +```python title="v1" +from typing import Any + +import mcp.types as types +from mcp.server.lowlevel import Server + +server = Server("Bookshop") + + +@server.list_tools() # (1)! +async def list_tools() -> list[types.Tool]: + return [ # (2)! + types.Tool( + name="search_books", + description="Search the catalog by title or author.", + inputSchema={ # (3)! + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + ) + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: # (4)! + if name != "search_books": + raise ValueError(f"Unknown tool: {name}") # (5)! + ctx = server.request_context # (6)! + return [types.TextContent(type="text", text=f"Found 3 books matching {arguments['query']!r}.")] # (7)! +``` + +1. Handlers are registered with decorators (called, with parentheses), any time after the server exists. +2. You return a bare `list[Tool]` and the SDK wraps it into a `ListToolsResult`. +3. Fields are camelCase in Python, and the schema is **enforced**: the SDK jsonschema-validates `call_tool` arguments against it before your function runs, which is why `arguments["query"]` below is safe. +4. One `call_tool` handler serves every tool, and it receives the tool name and the already-validated arguments, unpacked and never `None`. +5. Raising is how a v1 tool signals failure: any exception is caught and returned as `CallToolResult(isError=True)` with `str(e)` as its text, so the calling model reads this message and can retry. +6. The context comes from an ambient ContextVar, reached through the server object mid-request. +7. Bare content blocks are wrapped into a `CallToolResult` for you. + +```python title="v2" +--8<-- "docs_src/whats_new/tutorial001.py" +``` + +1. Fields are snake_case now, and the schema is **advertised but never applied**: nothing checks the arguments before your handler runs. +2. Every handler has the same shape: `async (ctx, params) -> result`. The context is the first argument (`ctx.session`, `ctx.request_id`, `ctx.protocol_version` live on it); this is where `server.request_context` went. +3. You build the full `ListToolsResult` yourself. Returning a bare list is a server-side `TypeError` now, not something the SDK wraps. +4. Typed params in (`params.name`, `params.arguments`), a full result out. Nothing is unpacked, wrapped, or converted for you. +5. Same check, different verb. A `ValueError` here would reach the model as an opaque `-32603` (see below), so a deliberate wire error is raised as `MCPError`: it passes through with its code and message intact, and `-32602` with this text is the spec's own answer for an unknown tool. +6. `params.arguments` can be `None`; v1 defaulted it to `{}` before your code ever saw it. With no validation in front of the handler, this line is load-bearing. +7. An unexpected exception raised here becomes a **sanitized** protocol error, `-32603` `"Internal server error"`: the model never sees the message. For a failure the model should read and react to, return `CallToolResult(is_error=True, ...)`. +8. Handlers are constructor arguments, so the server's surface is complete the moment it exists; `add_request_handler()` is the post-construction escape hatch, and the door to custom methods. + +The example is the pattern. More generally: every handler has the same shape, with typed params in and a full result type out; the old jsonschema check of tool arguments is gone; an exception is a protocol error, never an `is_error=True` tool result; and the ambient `server.request_context` ContextVar is gone. Custom, vendor-namespaced methods are first class through `add_request_handler(method, params_type, handler)`, which validates inbound params against your model before your handler runs. And a `middleware` list (deliberately marked provisional) wraps every inbound message, replacing the private `_handle_*` methods people used to override. + +Underneath, the v1 `BaseSession` receive loop was replaced by a dispatcher engine that the client and the server now share, and it is what makes several things on this page true at once: one `Server` object serves both protocol eras, `Client(server)` dispatches in process with no JSON-RPC framing, and a timed-out client request now actually cancels the server-side handler. + +**[The low-level Server](advanced/low-level-server.md)** is the page; the **[Migration Guide](migration.md#lowlevel-server-decorator-based-handlers-replaced-with-constructor-on_-params)** walks every removed hook. If you never dropped below `MCPServer`, none of this touches you. + +### The wire types moved to `mcp-types`, and every field is snake_case + +The protocol types now live in their own distribution, `mcp-types`, imported as `mcp_types`. It depends on nothing but pydantic and typing-extensions, so a gateway, a proxy, or a code generator can consume MCP's wire shapes without installing an HTTP stack. `mcp` depends on it at an exact version and re-exports the common names, so `from mcp import Tool` still works; `import mcp.types` does not. + +On those types, every Python attribute is now snake_case: `result.is_error`, `tool.input_schema`, `listing.next_cursor`. The JSON on the wire is camelCase, exactly as before; only the attribute spelling changed. Two stricter defaults ride along: unknown fields are ignored instead of round-tripped (put extras in `_meta`), and both sides validate traffic against the protocol version they negotiated. See the **[Migration Guide](migration.md#field-names-changed-from-camelcase-to-snake_case)** for the rename table. + +### Transport configuration moved to `run()` + +`MCPServer(...)` is about what your server *is*: its name, its instructions, its lifespan, its auth. How it is *served* now belongs to `run()` and the app builders, which is where `host`, `port`, `stateless_http`, `json_response`, the endpoint paths, and `transport_security` went (`MCPServer("x", port=9000)` is a `TypeError`). The overloads are typed per transport, so your editor tells you which options `stdio` takes and which `streamable-http` takes. One removal worth knowing: `mount_path` is gone; mounting the ASGI app is the supported way to serve under a prefix. + +**[Running your server](run/index.md)** covers the options; **[Add to an existing app](run/asgi.md)** covers mounting. + +### Behavior that changes without an import error + +The renames announce themselves. These do not: + +* **Sync functions run on a worker thread.** A `def` tool (or resource, prompt, or resolver) no longer blocks the event loop; the trade is that its body no longer runs *on* the event-loop thread, which matters to thread-affine code. `async def` handlers are untouched. **[Migration Guide](migration.md#sync-handler-functions-now-run-on-a-worker-thread)**. +* **`MCPError` (v1's `McpError`) raised inside a tool is a protocol error now.** The model never sees it. Every other exception still becomes an `is_error=True` result the model can read and react to. **[Handling errors](servers/handling-errors.md)** is the split. +* **Results are validated before they leave.** A hand-built `Tool` whose `input_schema` is `{}` now fails `tools/list` (the spec requires `"type": "object"`). Servers built on `@mcp.tool()` never see this; the SDK writes their schemas. +* **Your client validates what it receives.** `list_tools()` and `call_tool()` check the server's answer against the negotiated protocol version, so a not-quite-valid server that v1's lenient parse tolerated now raises `pydantic.ValidationError`. If you connect to servers you do not control, expect to be the one who finds them; the **[Migration Guide](migration.md#client-validates-inbound-traffic-against-the-protocol-schema)** has the details. +* **URI templates are real RFC 6570 now.** `{+path}`, `{?query}` and friends work, matching is exact instead of regex-loose, and path traversal in extracted values is rejected by default. Stricter templates fail at decoration time, not on the first request. **[URI templates](servers/uri-templates.md)**. +* **The streamable HTTP lifespan runs once**, at startup, and its state is shared by every session and request. In v1 it ran once per session, and once per request under `stateless_http=True`. Pools and caches built in a lifespan get dramatically cheaper; anything that acquired a per-connection resource there belongs in the handler body now. **[Lifespan](handlers/lifespan.md)**. +* **`mcp dev` and `mcp install` pin the environment they spawn** to your installed SDK version. Both commands run your server in a fresh `uv run --with ...` environment, which used to resolve `mcp` to the newest stable release rather than the version you are developing against. **[Migration Guide](migration.md#mcp-dev-and-mcp-install-pin-the-spawned-environment-to-your-sdk-version)**. + +### Removed outright + +Each of these is a section in the **[Migration Guide](migration.md)**: + +* The **WebSocket transport**, both sides, and the `mcp[ws]` extra. It was never part of the MCP specification. +* The **experimental Tasks** API (`mcp.*.experimental`). 2026-07-28 moves tasks out of the core protocol and into an official extension ([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663)), which this SDK does not implement yet. +* `mcp.types`, `mcp.shared.version`, and `mcp.shared.progress` as import paths. +* The deprecated `streamablehttp_client` spelling, and the `get_session_id` callback from `streamable_http_client` (which now yields exactly two streams). +* `McpError`, renamed **`MCPError`** with a direct `(code, message, data)` constructor. +* `MCPServer.get_context()`, `mount_path=`, and the lowlevel `Server`'s decorator methods, ContextVar, and handler dicts. + +## The protocol: 2025-11-25 to 2026-07-28 + +v2 implements the 2026-07-28 revision, and it serves **both** revisions at once: the same `streamable_http_app()` (and the same stdio server) answers a 2025-era client's `initialize` and a 2026-era client's requests with nothing to configure, no flag to flip, and no separate deployment. Serving the new revision does not strand a client on the old one. What follows is what the new revision itself changes. + +### No handshake, no session + +A 2026-07-28 client does not open a connection, negotiate, and then talk. Every request carries its protocol version, client info, and client capabilities in `_meta`, and the one discovery call, `server/discover`, is a plain request like any other. `Client` does the right thing by default: it probes `server/discover` once and falls back to the `initialize` handshake if the server is older. + +Over Streamable HTTP there is no `Mcp-Session-Id` on the 2026 path, which is the operational headline: **nothing ties a modern request to a worker**, so any replica behind a plain round-robin load balancer can answer it. Two honest qualifiers. Your 2025-era clients (today, that is most clients) still open sessions and still need whatever stickiness they needed on v1; nothing changes for them. And the one thing a *multi-round-trip* retry has to carry across workers is its sealed `request_state`, whose default key is minted per process, so a scaled-out deployment passes `RequestStateSecurity(keys=[...])`. (`stateless_http=True` is unrelated: it only affects how 2025-era clients are served, and 2026 traffic never reads it; if you already set it in v1, nothing changes.) + +**[Protocol versions](protocol-versions.md)** is the client's side of this, **[Deploy & scale](run/deploy.md)** is the operator's checklist (the Host allowlist, the `request_state` key, notifications across replicas), and **[Serving legacy clients](run/legacy-clients.md)** is the both-eras-at-once story. + +### The server cannot call the client: multi-round-trip requests + +Every server-initiated request is gone at 2026-07-28: push elicitation, sampling, `roots/list`. On a 2026 connection there is no channel for them, so `ctx.elicit()` and `ctx.session.create_message()` fail there with `NoBackChannelError` (they still work for legacy clients). + +The replacement turns the call around. A tool that needs something from the user *returns* the question (`InputRequiredResult`), the client answers it with the same callbacks it always had, and the call is retried with the answers attached. `Client` drives that loop for you. On the server you rarely build the result yourself, because a **[dependency](handlers/dependencies.md)** does it: annotate a parameter with `Resolve(ask_quantity)`, where `ask_quantity` is an ordinary function you write, and the SDK asks over whichever mechanism the connection supports, a live elicitation request on a legacy session or a multi-round-trip on 2026. One tool body, both eras: + +```python title="dual_era.py" hl_lines="24 37-38" +--8<-- "docs_src/legacy_clients/tutorial001.py" +``` + +That file is the pitch in one place: one server, one `Resolve`-backed tool, and a legacy client plus a modern client both getting their answer, in memory. **[Multi-round-trip requests](handlers/multi-round-trip.md)** explains the mechanism (including `request_state`, which the SDK seals and verifies for you); **[Elicitation](handlers/elicitation.md)** covers the asking. + +!!! warning "This is the one place a ported v1 server changes behavior" + Your own tests hit it first: `Client(mcp)` negotiates 2026-07-28 against your v2 server by + default, so a tool that calls `ctx.elicit()` fails in a test that passed on v1. Move the + question into a `Resolve(...)` parameter (era-portable), or pin the test client to + `mode="legacy"` if you genuinely want the push behavior. + +### Roots, sampling, and protocol logging are deprecated; `ping` is removed + +[SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) deprecates three whole *capabilities*, on every protocol version: roots, sampling, and MCP-level logging (`ctx.info()` and friends). That is a separate axis from the missing back-channel above; deprecated is advisory, everything keeps working against 2025-era sessions, and nothing changes on the wire. What you notice is `MCPDeprecationWarning`, which is a `UserWarning`, so it prints by default; expect your first `ctx.info(...)` after the upgrade to say so. + +`ping` is stricter: removed from the protocol, not deprecated. Two of the deprecated features' standalone methods are removed at 2026-07-28 the same way, `logging/setLevel` and the client's `notifications/roots/list_changed`, and progress notifications are now server-to-client only. + +**[Deprecated features](deprecated.md)** has the full table, the replacement for each, and the one-line filter if you need a quiet log while you serve legacy clients. + +### Change notifications become one stream + +At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are replaced by `subscriptions/listen`: the client opens one long-lived stream and names the notification kinds it wants. `MCPServer` serves it out of the box; you publish with `await ctx.notify_resource_updated(uri)` (and `notify_tools_changed()`, and so on), and multi-replica deployments plug in a shared `SubscriptionBus`. On the client (since `2.0.0b2`), `async with client.listen(...)` opens the stream: the filter goes in as keyword arguments, typed change events come back, and `sub.honored` is the subset the server agreed to deliver. One honest caveat: over stdio the server does not serve the stream yet. + +**[Subscriptions](handlers/subscriptions.md)** covers publishing and serving, **[its Clients twin](client/subscriptions.md)** the watching end, and **[Deploy & scale](run/deploy.md)** the bus. + +### The rest, quickly + +* **Requests are routable without parsing bodies.** Modern HTTP requests carry `Mcp-Method` (and, for the three tool-ish calls, `Mcp-Name`); a tool input-schema property annotated with `x-mcp-header` is mirrored into an `Mcp-Param-*` header and cross-checked by the server ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)). Gateways and rate limiters can route on headers alone; the **[Migration Guide](migration.md#servers-validate-mcp-param-headers-against-the-request-body-sep-2243)** has the rules. +* **Results carry cache hints.** List and read results declare `ttlMs` and `cacheScope` ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)); you set them per method with `cache_hints=`, and `Client` honors them with a built-in response cache. A server that sends no hints (every pre-2026 server) sees identical, uncached traffic. **[Caching hints](client/caching.md)**. +* **Extensions are first class.** Servers and clients declare optional capability bundles under reverse-DNS identifiers ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)); the built-in `Apps` extension (MCP Apps) is the reference. **[Extensions](advanced/extensions.md)** and **[MCP Apps](advanced/apps.md)**. +* **Error codes got standardized.** A missing resource is `-32602` with the URI in `error.data`, and the new spec-reserved codes appear as `-32020` (header mismatch), `-32021` (missing required capability), and `-32022` (unsupported protocol version). **[Troubleshooting](troubleshooting.md)** is keyed by the exact messages. +* **Authorization got harder to hold wrong.** The client validates the `iss` returned with the authorization code ([RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207); your `callback_handler` now returns an `AuthorizationCodeResult`), sends `application_type` when it registers, and never replays credentials against a different authorization server. New in the enterprise corner: the [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) identity-assertion flow. The **[Migration Guide](migration.md)** lists every OAuth change; **[OAuth for clients](client/oauth-clients.md)** and **[Identity assertion](client/identity-assertion.md)** are the pages. +* **Every server is traceable.** OpenTelemetry ships on by default as middleware: every request gets a server span, at no cost until the process configures an exporter. When both ends run the SDK, the client also propagates W3C trace context in `_meta`, so the traces join up. **[OpenTelemetry](run/opentelemetry.md)**. + +## Upgrading from v1? + +* The **[Migration Guide](migration.md)** is the complete, exact list of what to change; this page was the why. +* **v1.x is not going anywhere.** It stays the stable line, with critical fixes and security patches, and nothing about the 2026-07-28 spec release breaks it. If you publish a library that depends on `mcp`, add an upper bound (for example `mcp>=1.27,<2`) so stable v2 does not surprise your users. +* Something rough, confusing, or broken? **[File v2 feedback](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml)**; it all gets read. diff --git a/docs_src/__init__.py b/docs_src/__init__.py new file mode 100644 index 0000000..d19acbc --- /dev/null +++ b/docs_src/__init__.py @@ -0,0 +1,7 @@ +"""Complete, runnable source for every code example in `docs/`. + +Each `docs/<page>.md` includes its examples from `docs_src/<chapter>/tutorialNNN.py` +via `--8<--`, and `tests/docs_src/test_<chapter>.py` imports the same module and +exercises it through the in-memory `mcp.Client`. The file you read in the docs is +the file CI runs. +""" diff --git a/docs_src/apps/__init__.py b/docs_src/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/apps/report.html b/docs_src/apps/report.html new file mode 100644 index 0000000..7c94dee --- /dev/null +++ b/docs_src/apps/report.html @@ -0,0 +1,3 @@ +<!doctype html> +<title>Report +

Quarterly numbers render here.

diff --git a/docs_src/apps/tutorial001.py b/docs_src/apps/tutorial001.py new file mode 100644 index 0000000..27d9ede --- /dev/null +++ b/docs_src/apps/tutorial001.py @@ -0,0 +1,39 @@ +from mcp import Client +from mcp.client import advertise +from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID, Apps, client_supports_apps +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.context import Context + +CLOCK_HTML = """\ + +Clock +

...

+ +""" + +apps = Apps() + + +@apps.tool(resource_uri="ui://clock/app.html", description="The current time.") +def get_time(ctx: Context) -> str: + now = "2026-06-26T12:00:00Z" + if not client_supports_apps(ctx): + return f"The time is {now}." + return now + + +apps.add_html_resource("ui://clock/app.html", CLOCK_HTML, title="Clock") + +mcp = MCPServer("clock", extensions=[apps]) + + +async def main() -> None: + async with Client(mcp, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]) as client: + result = await client.call_tool("get_time", {}) + print(result.content) + # [TextContent(text='2026-06-26T12:00:00Z')] diff --git a/docs_src/apps/tutorial002.py b/docs_src/apps/tutorial002.py new file mode 100644 index 0000000..1139328 --- /dev/null +++ b/docs_src/apps/tutorial002.py @@ -0,0 +1,25 @@ +from mcp.server.apps import Apps, ResourceCsp, ResourcePermissions +from mcp.server.mcpserver import MCPServer + +DASHBOARD_HTML = "Dashboard" + +apps = Apps() + + +@apps.tool(resource_uri="ui://dashboard/app.html", visibility=["app"]) +def refresh_dashboard() -> str: + """Refresh the dashboard data.""" + return "refreshed" + + +apps.add_html_resource( + "ui://dashboard/app.html", + DASHBOARD_HTML, + title="Dashboard", + csp=ResourceCsp(connect_domains=["https://api.example.com"]), + permissions=ResourcePermissions(clipboard_write={}), + domain="dashboard.example.com", + prefers_border=True, +) + +mcp = MCPServer("dashboard", extensions=[apps]) diff --git a/docs_src/apps/tutorial003.py b/docs_src/apps/tutorial003.py new file mode 100644 index 0000000..e3aed3e --- /dev/null +++ b/docs_src/apps/tutorial003.py @@ -0,0 +1,20 @@ +from pathlib import Path + +from mcp.server.apps import Apps +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.resources import FileResource + +REPORT_HTML = Path(__file__).parent / "report.html" + +apps = Apps() + + +@apps.tool(resource_uri="ui://report/app.html") +def refresh_report() -> str: + """Refresh the report data.""" + return "report refreshed" + + +apps.add_resource(FileResource(uri="ui://report/app.html", name="report", path=REPORT_HTML)) + +mcp = MCPServer("report", extensions=[apps]) diff --git a/docs_src/asgi/__init__.py b/docs_src/asgi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/asgi/tutorial001.py b/docs_src/asgi/tutorial001.py new file mode 100644 index 0000000..800f19b --- /dev/null +++ b/docs_src/asgi/tutorial001.py @@ -0,0 +1,12 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Notes") + + +@mcp.tool() +def add_note(text: str) -> str: + """Save a note.""" + return f"Saved: {text}" + + +app = mcp.streamable_http_app() diff --git a/docs_src/asgi/tutorial002.py b/docs_src/asgi/tutorial002.py new file mode 100644 index 0000000..15e5388 --- /dev/null +++ b/docs_src/asgi/tutorial002.py @@ -0,0 +1,27 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server import MCPServer + +mcp = MCPServer("Notes") + + +@mcp.tool() +def add_note(text: str) -> str: + """Save a note.""" + return f"Saved: {text}" + + +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with mcp.session_manager.run(): + yield + + +app = Starlette( + routes=[Mount("/", app=mcp.streamable_http_app())], + lifespan=lifespan, +) diff --git a/docs_src/asgi/tutorial003.py b/docs_src/asgi/tutorial003.py new file mode 100644 index 0000000..cea736e --- /dev/null +++ b/docs_src/asgi/tutorial003.py @@ -0,0 +1,39 @@ +from collections.abc import AsyncIterator +from contextlib import AsyncExitStack, asynccontextmanager + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server import MCPServer + +notes = MCPServer("Notes") +tasks = MCPServer("Tasks") + + +@notes.tool() +def add_note(text: str) -> str: + """Save a note.""" + return f"Saved: {text}" + + +@tasks.tool() +def add_task(title: str) -> str: + """Create a task.""" + return f"Created: {title}" + + +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with AsyncExitStack() as stack: + await stack.enter_async_context(notes.session_manager.run()) + await stack.enter_async_context(tasks.session_manager.run()) + yield + + +app = Starlette( + routes=[ + Mount("/notes", app=notes.streamable_http_app()), + Mount("/tasks", app=tasks.streamable_http_app()), + ], + lifespan=lifespan, +) diff --git a/docs_src/asgi/tutorial004.py b/docs_src/asgi/tutorial004.py new file mode 100644 index 0000000..785a808 --- /dev/null +++ b/docs_src/asgi/tutorial004.py @@ -0,0 +1,27 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server import MCPServer + +mcp = MCPServer("Notes") + + +@mcp.tool() +def add_note(text: str) -> str: + """Save a note.""" + return f"Saved: {text}" + + +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with mcp.session_manager.run(): + yield + + +app = Starlette( + routes=[Mount("/notes", app=mcp.streamable_http_app(streamable_http_path="/"))], + lifespan=lifespan, +) diff --git a/docs_src/asgi/tutorial005.py b/docs_src/asgi/tutorial005.py new file mode 100644 index 0000000..abd6275 --- /dev/null +++ b/docs_src/asgi/tutorial005.py @@ -0,0 +1,52 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.cors import CORSMiddleware +from starlette.routing import Mount + +from mcp.server import MCPServer +from mcp.server.transport_security import TransportSecuritySettings + +mcp = MCPServer("Notes") + + +@mcp.tool() +def add_note(text: str) -> str: + """Save a note.""" + return f"Saved: {text}" + + +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with mcp.session_manager.run(): + yield + + +security = TransportSecuritySettings( + allowed_hosts=["mcp.example.com", "mcp.example.com:*"], + allowed_origins=["https://app.example.com"], +) + +app = Starlette( + routes=[Mount("/", app=mcp.streamable_http_app(transport_security=security))], + middleware=[ + Middleware( + CORSMiddleware, + allow_origins=["https://app.example.com"], + allow_methods=["GET", "POST", "DELETE"], + allow_headers=[ + "Authorization", + "Content-Type", + "Last-Event-ID", + "Mcp-Method", + "Mcp-Name", + "Mcp-Protocol-Version", + "Mcp-Session-Id", + ], + expose_headers=["Mcp-Session-Id"], + ) + ], + lifespan=lifespan, +) diff --git a/docs_src/asgi/tutorial006.py b/docs_src/asgi/tutorial006.py new file mode 100644 index 0000000..a3554ec --- /dev/null +++ b/docs_src/asgi/tutorial006.py @@ -0,0 +1,20 @@ +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +from mcp.server import MCPServer + +mcp = MCPServer("Notes") + + +@mcp.tool() +def add_note(text: str) -> str: + """Save a note.""" + return f"Saved: {text}" + + +@mcp.custom_route("/health", methods=["GET"]) +async def health(request: Request) -> Response: + return JSONResponse({"status": "ok"}) + + +app = mcp.streamable_http_app() diff --git a/docs_src/authorization/__init__.py b/docs_src/authorization/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/authorization/tutorial001.py b/docs_src/authorization/tutorial001.py new file mode 100644 index 0000000..f15f54f --- /dev/null +++ b/docs_src/authorization/tutorial001.py @@ -0,0 +1,31 @@ +from pydantic import AnyHttpUrl + +from mcp.server import MCPServer +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.server.auth.settings import AuthSettings + +KNOWN_TOKENS = { + "alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"]), +} + + +class StaticTokenVerifier(TokenVerifier): + async def verify_token(self, token: str) -> AccessToken | None: + return KNOWN_TOKENS.get(token) + + +mcp = MCPServer( + "Notes", + token_verifier=StaticTokenVerifier(), + auth=AuthSettings( + issuer_url=AnyHttpUrl("https://auth.example.com"), + resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"), + required_scopes=["notes:read"], + ), +) + + +@mcp.tool() +def list_notes() -> list[str]: + """List every note in the notebook.""" + return ["Buy milk", "Ship the release"] diff --git a/docs_src/authorization/tutorial002.py b/docs_src/authorization/tutorial002.py new file mode 100644 index 0000000..55b024f --- /dev/null +++ b/docs_src/authorization/tutorial002.py @@ -0,0 +1,35 @@ +from pydantic import AnyHttpUrl + +from mcp.server import MCPServer +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.server.auth.settings import AuthSettings + +KNOWN_TOKENS = { + "alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"]), +} + + +class StaticTokenVerifier(TokenVerifier): + async def verify_token(self, token: str) -> AccessToken | None: + return KNOWN_TOKENS.get(token) + + +mcp = MCPServer( + "Notes", + token_verifier=StaticTokenVerifier(), + auth=AuthSettings( + issuer_url=AnyHttpUrl("https://auth.example.com"), + resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"), + required_scopes=["notes:read"], + ), +) + + +@mcp.tool() +def whoami() -> str: + """Report which OAuth client is calling.""" + token = get_access_token() + if token is None: + return "anonymous" + return f"{token.client_id} (scopes: {', '.join(token.scopes)})" diff --git a/docs_src/caching/__init__.py b/docs_src/caching/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/caching/tutorial001.py b/docs_src/caching/tutorial001.py new file mode 100644 index 0000000..73e686d --- /dev/null +++ b/docs_src/caching/tutorial001.py @@ -0,0 +1,19 @@ +from mcp.server import CacheHint, MCPServer + +mcp = MCPServer( + "Weather", + cache_hints={ + "tools/list": CacheHint(ttl_ms=60_000, scope="public"), + "resources/read": CacheHint(ttl_ms=5_000), + }, +) + + +@mcp.tool() +def forecast(city: str) -> str: + return f"Sunny in {city}" + + +@mcp.resource("config://units") +def units() -> str: + return "metric" diff --git a/docs_src/caching/tutorial002.py b/docs_src/caching/tutorial002.py new file mode 100644 index 0000000..6bbfec9 --- /dev/null +++ b/docs_src/caching/tutorial002.py @@ -0,0 +1,18 @@ +from typing import Any + +from mcp_types import ListToolsResult, PaginatedRequestParams, Tool + +from mcp.server import CacheHint, Server, ServerRequestContext + +TOOLS = [Tool(name="forecast", input_schema={"type": "object"})] + + +async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=TOOLS, ttl_ms=1_000) + + +server = Server( + "Weather", + on_list_tools=list_tools, + cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")}, +) diff --git a/docs_src/caching/tutorial003.py b/docs_src/caching/tutorial003.py new file mode 100644 index 0000000..29c168c --- /dev/null +++ b/docs_src/caching/tutorial003.py @@ -0,0 +1,40 @@ +from dataclasses import dataclass +from typing import Any + +from mcp_types import ListToolsResult, PaginatedRequestParams, Tool + +from mcp import Client +from mcp.client import CacheConfig +from mcp.server import CacheHint, Server, ServerRequestContext + + +@dataclass +class DemoState: + fetches: int = 0 + now: float = 1_000_000.0 + + +state = DemoState() + + +async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult: + state.fetches += 1 + return ListToolsResult(tools=[Tool(name="forecast", input_schema={"type": "object"})]) + + +server = Server( + "Weather", + on_list_tools=list_tools, + cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")}, +) + + +async def main() -> None: + start = state.fetches + async with Client(server, cache=CacheConfig(clock=lambda: state.now)) as client: + await client.list_tools() # fetch 1 + await client.list_tools() # fresh for 60s: served from the cache + state.now += 60.0 + await client.list_tools() # the TTL ran out: fetch 2 + await client.list_tools(cache_mode="refresh") # skip the cache read: fetch 3 + print(f"4 calls, {state.fetches - start} fetches") diff --git a/docs_src/client/__init__.py b/docs_src/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/client/tutorial001.py b/docs_src/client/tutorial001.py new file mode 100644 index 0000000..b020926 --- /dev/null +++ b/docs_src/client/tutorial001.py @@ -0,0 +1,18 @@ +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop", instructions="Search the catalog before recommending a book.") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +async def main() -> None: + async with Client(mcp) as client: + print(client.server_info) + print(client.server_capabilities) + print(client.protocol_version) + print(client.instructions) diff --git a/docs_src/client/tutorial002.py b/docs_src/client/tutorial002.py new file mode 100644 index 0000000..a3e379a --- /dev/null +++ b/docs_src/client/tutorial002.py @@ -0,0 +1,20 @@ +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool(title="Search the catalog") +def search_books(query: str, limit: int = 10) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r} (showing up to {limit})." + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.list_tools() + for tool in result.tools: + print(tool.name) + print(tool.title) + print(tool.description) + print(tool.input_schema) diff --git a/docs_src/client/tutorial003.py b/docs_src/client/tutorial003.py new file mode 100644 index 0000000..1aeab63 --- /dev/null +++ b/docs_src/client/tutorial003.py @@ -0,0 +1,33 @@ +from mcp_types import TextContent +from pydantic import BaseModel + +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +class Book(BaseModel): + title: str + author: str + year: int + + +@mcp.tool() +def lookup_book(title: str) -> Book: + """Look up a book by its exact title.""" + if title != "Dune": + raise ValueError(f"No book titled {title!r} in the catalog.") + return Book(title="Dune", author="Frank Herbert", year=1965) + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.call_tool("lookup_book", {"title": "Dune"}) + + for block in result.content: + if isinstance(block, TextContent): + print(block.text) + + print(result.structured_content) + print(result.is_error) diff --git a/docs_src/client/tutorial004.py b/docs_src/client/tutorial004.py new file mode 100644 index 0000000..fddcde9 --- /dev/null +++ b/docs_src/client/tutorial004.py @@ -0,0 +1,32 @@ +from mcp_types import TextResourceContents + +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.resource("catalog://genres") +def genres() -> list[str]: + """The genres the catalog is organised by.""" + return ["fiction", "non-fiction", "poetry"] + + +@mcp.resource("catalog://genres/{genre}") +def books_in_genre(genre: str) -> str: + """Every title we stock in one genre.""" + return f"3 books filed under {genre}." + + +async def main() -> None: + async with Client(mcp) as client: + listed = await client.list_resources() + print([resource.uri for resource in listed.resources]) + + templates = await client.list_resource_templates() + print([template.uri_template for template in templates.resource_templates]) + + result = await client.read_resource("catalog://genres/poetry") + for contents in result.contents: + if isinstance(contents, TextResourceContents): + print(contents.text) diff --git a/docs_src/client/tutorial005.py b/docs_src/client/tutorial005.py new file mode 100644 index 0000000..ce4d164 --- /dev/null +++ b/docs_src/client/tutorial005.py @@ -0,0 +1,20 @@ +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.prompt(title="Recommend a book") +def recommend(genre: str) -> str: + """Ask for a recommendation in a genre.""" + return f"Recommend one {genre} book from the catalog and say why." + + +async def main() -> None: + async with Client(mcp) as client: + listed = await client.list_prompts() + print(listed.prompts) + + result = await client.get_prompt("recommend", {"genre": "poetry"}) + for message in result.messages: + print(message.role, message.content) diff --git a/docs_src/client/tutorial006.py b/docs_src/client/tutorial006.py new file mode 100644 index 0000000..b76b6a0 --- /dev/null +++ b/docs_src/client/tutorial006.py @@ -0,0 +1,32 @@ +from mcp_types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference + +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + +GENRES = ["fiction", "non-fiction", "poetry"] + + +@mcp.prompt() +def recommend(genre: str) -> str: + """Ask for a recommendation in a genre.""" + return f"Recommend one {genre} book from the catalog and say why." + + +@mcp.completion() +async def complete_genre( + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, +) -> Completion | None: + return Completion(values=[genre for genre in GENRES if genre.startswith(argument.value)]) + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.complete( + ref=PromptReference(type="ref/prompt", name="recommend"), + argument={"name": "genre", "value": "p"}, + ) + print(result.completion.values) diff --git a/docs_src/client/tutorial007.py b/docs_src/client/tutorial007.py new file mode 100644 index 0000000..594b052 --- /dev/null +++ b/docs_src/client/tutorial007.py @@ -0,0 +1,31 @@ +from mcp_types import Tool + +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +@mcp.tool() +def reserve_book(title: str) -> str: + """Put a book on hold.""" + return f"Reserved {title!r}." + + +async def main() -> None: + async with Client(mcp) as client: + tools: list[Tool] = [] + cursor: str | None = None + while True: + page = await client.list_tools(cursor=cursor) + tools.extend(page.tools) + if page.next_cursor is None: + break + cursor = page.next_cursor + print([tool.name for tool in tools]) diff --git a/docs_src/client_callbacks/__init__.py b/docs_src/client_callbacks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/client_callbacks/tutorial001.py b/docs_src/client_callbacks/tutorial001.py new file mode 100644 index 0000000..154bc4d --- /dev/null +++ b/docs_src/client_callbacks/tutorial001.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel + +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Library") + + +class CardHolder(BaseModel): + name: str + + +@mcp.tool() +async def issue_card(ctx: Context) -> str: + """Issue a new library card.""" + answer = await ctx.elicit("What name should go on the card?", schema=CardHolder) + if answer.action == "accept": + return f"Card issued to {answer.data.name}." + return "No card issued." diff --git a/docs_src/client_callbacks/tutorial002.py b/docs_src/client_callbacks/tutorial002.py new file mode 100644 index 0000000..2bae985 --- /dev/null +++ b/docs_src/client_callbacks/tutorial002.py @@ -0,0 +1,21 @@ +from mcp_types import ElicitRequestParams, ElicitResult + +from mcp import Client +from mcp.client import ClientRequestContext + + +async def handle_elicitation( + context: ClientRequestContext, + params: ElicitRequestParams, +) -> ElicitResult: + return ElicitResult(action="accept", content={"name": "Ada Lovelace"}) + + +async def main() -> None: + async with Client( + "http://127.0.0.1:8000/mcp", + mode="legacy", + elicitation_callback=handle_elicitation, + ) as client: + result = await client.call_tool("issue_card") + print(result.content) diff --git a/docs_src/client_callbacks/tutorial003.py b/docs_src/client_callbacks/tutorial003.py new file mode 100644 index 0000000..c7a269a --- /dev/null +++ b/docs_src/client_callbacks/tutorial003.py @@ -0,0 +1,31 @@ +from mcp_types import ClientCapabilities, ElicitationCapability, RootsCapability, SamplingCapability +from pydantic import BaseModel + +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Library") + + +class CardHolder(BaseModel): + name: str + + +@mcp.tool() +async def issue_card(ctx: Context) -> str: + """Issue a new library card.""" + answer = await ctx.elicit("What name should go on the card?", schema=CardHolder) + if answer.action == "accept": + return f"Card issued to {answer.data.name}." + return "No card issued." + + +@mcp.tool() +def client_features(ctx: Context) -> list[str]: + """Which optional features the connected client declared.""" + declared = { + "elicitation": ClientCapabilities(elicitation=ElicitationCapability()), + "sampling": ClientCapabilities(sampling=SamplingCapability()), + "roots": ClientCapabilities(roots=RootsCapability()), + } + return [name for name, capability in declared.items() if ctx.session.check_client_capability(capability)] diff --git a/docs_src/client_callbacks/tutorial004.py b/docs_src/client_callbacks/tutorial004.py new file mode 100644 index 0000000..20c9b81 --- /dev/null +++ b/docs_src/client_callbacks/tutorial004.py @@ -0,0 +1,19 @@ +from mcp_types import CreateMessageRequestParams, CreateMessageResult, ListRootsResult, Root, TextContent +from pydantic import FileUrl + +from mcp.client import ClientRequestContext + + +async def handle_sampling( + context: ClientRequestContext, + params: CreateMessageRequestParams, +) -> CreateMessageResult: + return CreateMessageResult( + role="assistant", + content=TextContent(type="text", text="The answer is 42."), + model="my-llm", + ) + + +async def handle_list_roots(context: ClientRequestContext) -> ListRootsResult: + return ListRootsResult(roots=[Root(uri=FileUrl("file:///home/ada/notebooks"), name="notebooks")]) diff --git a/docs_src/client_transports/__init__.py b/docs_src/client_transports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/client_transports/tutorial001.py b/docs_src/client_transports/tutorial001.py new file mode 100644 index 0000000..5920f3d --- /dev/null +++ b/docs_src/client_transports/tutorial001.py @@ -0,0 +1,16 @@ +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.call_tool("search_books", {"query": "dune"}) + print(result.structured_content) diff --git a/docs_src/client_transports/tutorial002.py b/docs_src/client_transports/tutorial002.py new file mode 100644 index 0000000..8350a90 --- /dev/null +++ b/docs_src/client_transports/tutorial002.py @@ -0,0 +1,7 @@ +from mcp import Client + + +async def main() -> None: + async with Client("http://localhost:8000/mcp") as client: + result = await client.list_tools() + print([tool.name for tool in result.tools]) diff --git a/docs_src/client_transports/tutorial003.py b/docs_src/client_transports/tutorial003.py new file mode 100644 index 0000000..0134a72 --- /dev/null +++ b/docs_src/client_transports/tutorial003.py @@ -0,0 +1,16 @@ +import httpx + +from mcp import Client +from mcp.client.streamable_http import streamable_http_client + + +async def main() -> None: + async with httpx.AsyncClient( + headers={"Authorization": "Bearer ..."}, + timeout=httpx.Timeout(30.0, read=300.0), + follow_redirects=True, + ) as http_client: + transport = streamable_http_client("http://localhost:8000/mcp", http_client=http_client) + async with Client(transport) as client: + result = await client.list_tools() + print([tool.name for tool in result.tools]) diff --git a/docs_src/client_transports/tutorial004.py b/docs_src/client_transports/tutorial004.py new file mode 100644 index 0000000..8e07e09 --- /dev/null +++ b/docs_src/client_transports/tutorial004.py @@ -0,0 +1,14 @@ +from mcp import Client, StdioServerParameters +from mcp.client.stdio import stdio_client + +server = StdioServerParameters( + command="uv", + args=["run", "server.py"], + env={"BOOKSHOP_API_KEY": "secret"}, +) + + +async def main() -> None: + async with Client(stdio_client(server)) as client: + result = await client.list_tools() + print([tool.name for tool in result.tools]) diff --git a/docs_src/completions/__init__.py b/docs_src/completions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/completions/tutorial001.py b/docs_src/completions/tutorial001.py new file mode 100644 index 0000000..8326a65 --- /dev/null +++ b/docs_src/completions/tutorial001.py @@ -0,0 +1,15 @@ +from mcp.server import MCPServer + +mcp = MCPServer("GitHub Explorer") + + +@mcp.resource("github://repos/{owner}/{repo}") +def github_repo(owner: str, repo: str) -> str: + """A GitHub repository.""" + return f"Repository: {owner}/{repo}" + + +@mcp.prompt() +def review_code(language: str, code: str) -> str: + """Review a snippet of code.""" + return f"Review this {language} code:\n{code}" diff --git a/docs_src/completions/tutorial002.py b/docs_src/completions/tutorial002.py new file mode 100644 index 0000000..4715277 --- /dev/null +++ b/docs_src/completions/tutorial002.py @@ -0,0 +1,30 @@ +from mcp_types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference + +from mcp.server import MCPServer + +mcp = MCPServer("GitHub Explorer") + +LANGUAGES = ["go", "javascript", "python", "rust", "typescript"] + + +@mcp.resource("github://repos/{owner}/{repo}") +def github_repo(owner: str, repo: str) -> str: + """A GitHub repository.""" + return f"Repository: {owner}/{repo}" + + +@mcp.prompt() +def review_code(language: str, code: str) -> str: + """Review a snippet of code.""" + return f"Review this {language} code:\n{code}" + + +@mcp.completion() +async def handle_completion( + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, +) -> Completion | None: + if isinstance(ref, PromptReference) and argument.name == "language": + return Completion(values=[lang for lang in LANGUAGES if lang.startswith(argument.value)]) + return None diff --git a/docs_src/completions/tutorial003.py b/docs_src/completions/tutorial003.py new file mode 100644 index 0000000..3cbe21b --- /dev/null +++ b/docs_src/completions/tutorial003.py @@ -0,0 +1,40 @@ +from mcp_types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference + +from mcp.server import MCPServer + +mcp = MCPServer("GitHub Explorer") + +LANGUAGES = ["go", "javascript", "python", "rust", "typescript"] + +REPOS_BY_OWNER = { + "modelcontextprotocol": ["python-sdk", "typescript-sdk", "inspector"], + "pydantic": ["pydantic", "pydantic-ai", "logfire"], +} + + +@mcp.resource("github://repos/{owner}/{repo}") +def github_repo(owner: str, repo: str) -> str: + """A GitHub repository.""" + return f"Repository: {owner}/{repo}" + + +@mcp.prompt() +def review_code(language: str, code: str) -> str: + """Review a snippet of code.""" + return f"Review this {language} code:\n{code}" + + +@mcp.completion() +async def handle_completion( + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, +) -> Completion | None: + if isinstance(ref, PromptReference) and argument.name == "language": + return Completion(values=[lang for lang in LANGUAGES if lang.startswith(argument.value)]) + if isinstance(ref, ResourceTemplateReference) and argument.name == "repo": + if context is None or context.arguments is None: + return None + repos = REPOS_BY_OWNER.get(context.arguments.get("owner", ""), []) + return Completion(values=[repo for repo in repos if repo.startswith(argument.value)]) + return None diff --git a/docs_src/context/__init__.py b/docs_src/context/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/context/tutorial001.py b/docs_src/context/tutorial001.py new file mode 100644 index 0000000..1666ca5 --- /dev/null +++ b/docs_src/context/tutorial001.py @@ -0,0 +1,10 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str, ctx: Context) -> str: + """Search the catalog by title or author.""" + return f"[request {ctx.request_id}] Found 3 books matching {query!r}." diff --git a/docs_src/context/tutorial002.py b/docs_src/context/tutorial002.py new file mode 100644 index 0000000..f85caf0 --- /dev/null +++ b/docs_src/context/tutorial002.py @@ -0,0 +1,17 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Bookshop") + + +@mcp.resource("catalog://genres") +def genres() -> str: + """The genres the catalog is organised into.""" + return "fiction, non-fiction, poetry" + + +@mcp.tool() +async def describe_catalog(ctx: Context) -> str: + """Describe how the catalog is organised.""" + [contents] = await ctx.read_resource("catalog://genres") + return f"The catalog is organised into: {contents.content}" diff --git a/docs_src/context/tutorial003.py b/docs_src/context/tutorial003.py new file mode 100644 index 0000000..d3a741d --- /dev/null +++ b/docs_src/context/tutorial003.py @@ -0,0 +1,17 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Bookshop") + + +def recommend_book(genre: str) -> str: + """Recommend a book in the given genre.""" + return f"In {genre}, try 'Dune'." + + +@mcp.tool() +async def enable_recommendations(ctx: Context) -> str: + """Switch on the recommendation tool.""" + mcp.add_tool(recommend_book) + await ctx.session.send_tool_list_changed() + return "Recommendations are now available." diff --git a/docs_src/dependencies/__init__.py b/docs_src/dependencies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/dependencies/tutorial001.py b/docs_src/dependencies/tutorial001.py new file mode 100644 index 0000000..182b544 --- /dev/null +++ b/docs_src/dependencies/tutorial001.py @@ -0,0 +1,27 @@ +from typing import Annotated + +from pydantic import BaseModel + +from mcp.server import MCPServer +from mcp.server.mcpserver import Resolve + +mcp = MCPServer("Bookshop") + +INVENTORY = {"Dune": 7, "Neuromancer": 0} + + +class Stock(BaseModel): + title: str + copies: int + + +async def check_stock(title: str) -> Stock: + return Stock(title=title, copies=INVENTORY.get(title, 0)) + + +@mcp.tool() +async def reserve_book(title: str, stock: Annotated[Stock, Resolve(check_stock)]) -> str: + """Reserve a copy of a book.""" + if stock.copies == 0: + return f"{title!r} is out of stock." + return f"Reserved {title!r} ({stock.copies - 1} copies left)." diff --git a/docs_src/dependencies/tutorial002.py b/docs_src/dependencies/tutorial002.py new file mode 100644 index 0000000..3f24e2c --- /dev/null +++ b/docs_src/dependencies/tutorial002.py @@ -0,0 +1,35 @@ +from typing import Annotated + +from pydantic import BaseModel + +from mcp.server import MCPServer +from mcp.server.mcpserver import Resolve + +mcp = MCPServer("Bookshop") + +INVENTORY = {"Dune": 7, "Neuromancer": 0} + + +class Stock(BaseModel): + title: str + copies: int + + +async def check_stock(title: str) -> Stock: + return Stock(title=title, copies=INVENTORY.get(title, 0)) + + +async def estimate_delivery(stock: Annotated[Stock, Resolve(check_stock)]) -> str: + return "tomorrow" if stock.copies > 0 else "in 2-3 weeks" + + +@mcp.tool() +async def order_book( + title: str, + stock: Annotated[Stock, Resolve(check_stock)], + delivery: Annotated[str, Resolve(estimate_delivery)], +) -> str: + """Order a book from the shop.""" + if stock.copies == 0: + return f"{title!r} is on backorder; it would arrive {delivery}." + return f"Ordered {title!r}; it arrives {delivery}." diff --git a/docs_src/dependencies/tutorial003.py b/docs_src/dependencies/tutorial003.py new file mode 100644 index 0000000..5125266 --- /dev/null +++ b/docs_src/dependencies/tutorial003.py @@ -0,0 +1,46 @@ +from typing import Annotated + +from pydantic import BaseModel, Field + +from mcp.server import MCPServer +from mcp.server.mcpserver import Elicit, Resolve + +mcp = MCPServer("Bookshop") + +INVENTORY = {"Dune": 7, "Neuromancer": 0} + + +class Stock(BaseModel): + title: str + copies: int + + +class Backorder(BaseModel): + confirm: bool = Field(description="Order anyway and wait?") + + +async def check_stock(title: str) -> Stock: + return Stock(title=title, copies=INVENTORY.get(title, 0)) + + +async def confirm_backorder( + title: str, + stock: Annotated[Stock, Resolve(check_stock)], +) -> Backorder | Elicit[Backorder]: + if stock.copies > 0: + return Backorder(confirm=True) # in stock: nothing to ask + return Elicit(f"{title!r} is out of stock (2-3 weeks). Order anyway?", Backorder) + + +@mcp.tool() +async def order_book( + title: str, + stock: Annotated[Stock, Resolve(check_stock)], + backorder: Annotated[Backorder, Resolve(confirm_backorder)], +) -> str: + """Order a book from the shop.""" + if not backorder.confirm: + return "No order placed." + if stock.copies == 0: + return f"Backordered {title!r}; it ships in 2-3 weeks." + return f"Ordered {title!r}." diff --git a/docs_src/dependencies/tutorial004.py b/docs_src/dependencies/tutorial004.py new file mode 100644 index 0000000..ff55e5c --- /dev/null +++ b/docs_src/dependencies/tutorial004.py @@ -0,0 +1,26 @@ +from typing import Annotated + +from mcp_types import CreateMessageResult, SamplingMessage, TextContent + +from mcp.server import MCPServer +from mcp.server.mcpserver import Resolve, Sample + +mcp = MCPServer("Bookshop") + + +def suggest_title(genre: str) -> Sample: + prompt = f"Suggest one {genre} book title. Answer with the title only." + return Sample( + [SamplingMessage(role="user", content=TextContent(type="text", text=prompt))], + max_tokens=50, + ) + + +@mcp.tool() +async def recommend_book( + genre: str, + suggestion: Annotated[CreateMessageResult, Resolve(suggest_title)], +) -> str: + """Recommend a book in the given genre.""" + title = suggestion.content.text if suggestion.content.type == "text" else "the classics" + return f"Today's {genre} pick: {title}" diff --git a/docs_src/deploy/__init__.py b/docs_src/deploy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/deploy/tutorial001.py b/docs_src/deploy/tutorial001.py new file mode 100644 index 0000000..7b00259 --- /dev/null +++ b/docs_src/deploy/tutorial001.py @@ -0,0 +1,17 @@ +from mcp.server import MCPServer +from mcp.server.transport_security import TransportSecuritySettings + +mcp = MCPServer("Notes") + + +@mcp.tool() +def add_note(text: str) -> str: + """Save a note.""" + return f"Saved: {text}" + + +security = TransportSecuritySettings( + allowed_hosts=["mcp.example.com", "mcp.example.com:*"], + allowed_origins=["https://app.example.com"], +) +app = mcp.streamable_http_app(transport_security=security) diff --git a/docs_src/deploy/tutorial002.py b/docs_src/deploy/tutorial002.py new file mode 100644 index 0000000..8b61aac --- /dev/null +++ b/docs_src/deploy/tutorial002.py @@ -0,0 +1,27 @@ +from mcp_types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult + +from mcp.server.mcpserver import Context, MCPServer + +CONFIRM = ElicitRequest( + params=ElicitRequestFormParams( + message="Issue this refund?", + requested_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}, "required": ["ok"]}, + ) +) + + +def make_server() -> MCPServer: + """Every worker process builds one of these, once, at import.""" + mcp = MCPServer("billing") + + @mcp.tool() + async def refund(amount: int, ctx: Context) -> str | InputRequiredResult: + """Refund an amount, once a human has confirmed it.""" + if ctx.input_responses is None: + return InputRequiredResult(input_requests={"ok": CONFIRM}, request_state=f"refund:{amount}") + answer = (ctx.input_responses or {}).get("ok") + if not isinstance(answer, ElicitResult) or answer.action != "accept" or not (answer.content or {}).get("ok"): + return "refund cancelled" + return f"refunded ${amount}" + + return mcp diff --git a/docs_src/deploy/tutorial003.py b/docs_src/deploy/tutorial003.py new file mode 100644 index 0000000..8d9d126 --- /dev/null +++ b/docs_src/deploy/tutorial003.py @@ -0,0 +1,27 @@ +from mcp_types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult + +from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity + +CONFIRM = ElicitRequest( + params=ElicitRequestFormParams( + message="Issue this refund?", + requested_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}, "required": ["ok"]}, + ) +) + + +def make_server(key: str) -> MCPServer: + """Every worker process: the same key, and the same name.""" + mcp = MCPServer("billing", request_state_security=RequestStateSecurity(keys=[key])) + + @mcp.tool() + async def refund(amount: int, ctx: Context) -> str | InputRequiredResult: + """Refund an amount, once a human has confirmed it.""" + if ctx.input_responses is None: + return InputRequiredResult(input_requests={"ok": CONFIRM}, request_state=f"refund:{amount}") + answer = (ctx.input_responses or {}).get("ok") + if not isinstance(answer, ElicitResult) or answer.action != "accept" or not (answer.content or {}).get("ok"): + return "refund cancelled" + return f"refunded ${amount}" + + return mcp diff --git a/docs_src/deploy/tutorial004.py b/docs_src/deploy/tutorial004.py new file mode 100644 index 0000000..5f32c65 --- /dev/null +++ b/docs_src/deploy/tutorial004.py @@ -0,0 +1,23 @@ +from mcp.server.mcpserver import Context, MCPServer +from mcp.server.subscriptions import SubscriptionBus + +NOTES = {"todo": "buy milk"} + + +def make_server(bus: SubscriptionBus) -> MCPServer: + """Every replica gets its own server object; all of them hold the same bus.""" + mcp = MCPServer("Notebook", subscriptions=bus) + + @mcp.resource("note://{name}") + def note(name: str) -> str: + """One note, by name.""" + return NOTES[name] + + @mcp.tool() + async def edit_note(name: str, text: str, ctx: Context) -> str: + """Replace a note's text.""" + NOTES[name] = text + await ctx.notify_resource_updated(f"note://{name}") + return "saved" + + return mcp diff --git a/docs_src/elicitation/__init__.py b/docs_src/elicitation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/elicitation/tutorial001.py b/docs_src/elicitation/tutorial001.py new file mode 100644 index 0000000..0831948 --- /dev/null +++ b/docs_src/elicitation/tutorial001.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel, Field + +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Bistro") + + +class AlternativeDate(BaseModel): + accept_alternative: bool = Field(description="Try another date?") + date: str = Field(default="2025-12-26", description="Alternative date (YYYY-MM-DD)") + + +@mcp.tool() +async def book_table(date: str, party_size: int, ctx: Context) -> str: + """Book a table at the bistro.""" + if date != "2025-12-25": + return f"Booked a table for {party_size} on {date}." + + result = await ctx.elicit( + message=f"No tables for {party_size} on {date}. Would you like to try another date?", + schema=AlternativeDate, + ) + if result.action == "accept" and result.data.accept_alternative: + return await book_table(result.data.date, party_size, ctx) + return "No booking made." diff --git a/docs_src/elicitation/tutorial002.py b/docs_src/elicitation/tutorial002.py new file mode 100644 index 0000000..b8e3456 --- /dev/null +++ b/docs_src/elicitation/tutorial002.py @@ -0,0 +1,24 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Bistro") + + +@mcp.tool() +async def pay_deposit(booking_id: str, ctx: Context) -> str: + """Take the deposit that confirms a booking.""" + result = await ctx.elicit_url( + message="A 20 EUR deposit confirms your booking.", + url=f"https://pay.example.com/deposit/{booking_id}", + elicitation_id=f"deposit-{booking_id}", + ) + if result.action == "accept": + return "Complete the payment in your browser." + return "No deposit taken. The booking expires in one hour." + + +@mcp.tool() +async def confirm_deposit(booking_id: str, ctx: Context) -> str: + """Record a payment reported by the payment provider.""" + await ctx.session.send_elicit_complete(f"deposit-{booking_id}") + return f"Deposit received for booking {booking_id}." diff --git a/docs_src/elicitation/tutorial003.py b/docs_src/elicitation/tutorial003.py new file mode 100644 index 0000000..f6bb402 --- /dev/null +++ b/docs_src/elicitation/tutorial003.py @@ -0,0 +1,22 @@ +from mcp_types import ElicitRequestParams, ElicitRequestURLParams, ElicitResult + +from mcp import Client +from mcp.client import ClientRequestContext + + +async def handle_elicitation(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + if isinstance(params, ElicitRequestURLParams): + print(f"Open this link to continue: {params.url}") + return ElicitResult(action="accept") + print(params.message) + return ElicitResult(action="accept", content={"accept_alternative": True, "date": "2025-12-27"}) + + +async def main() -> None: + async with Client( + "http://127.0.0.1:8000/mcp", + mode="legacy", + elicitation_callback=handle_elicitation, + ) as client: + result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2}) + print(result.content) diff --git a/docs_src/elicitation/tutorial004.py b/docs_src/elicitation/tutorial004.py new file mode 100644 index 0000000..1edec06 --- /dev/null +++ b/docs_src/elicitation/tutorial004.py @@ -0,0 +1,47 @@ +from typing import Annotated + +from pydantic import BaseModel + +from mcp.server import MCPServer +from mcp.server.mcpserver import ( + AcceptedElicitation, + CancelledElicitation, + DeclinedElicitation, + Elicit, + ElicitationResult, + Resolve, +) + +mcp = MCPServer("Files") + +_FOLDERS: dict[str, list[str]] = {"/tmp/empty": [], "/tmp/project": ["main.py", "README.md"]} + + +class Confirm(BaseModel): + ok: bool + + +async def confirm_delete(path: str) -> Confirm | Elicit[Confirm]: + """Resolver: ask for confirmation only when the folder is not empty.""" + file_count = len(_FOLDERS.get(path, [])) + if file_count == 0: + return Confirm(ok=True) # nothing to confirm, no round-trip to the client + return Elicit(f"{path} has {file_count} file(s). Delete anyway?", Confirm) + + +@mcp.tool() +async def delete_folder( + path: str, + confirm: Annotated[ElicitationResult[Confirm], Resolve(confirm_delete)], +) -> str: + """Delete a folder, asking for confirmation when it is not empty.""" + match confirm: + case AcceptedElicitation(data=Confirm(ok=True)): + _FOLDERS.pop(path, None) + return f"deleted {path}" + case AcceptedElicitation(): + return "kept the folder" + case DeclinedElicitation(): + return "declined: folder not deleted" + case CancelledElicitation(): + return "cancelled: folder not deleted" diff --git a/docs_src/extensions/__init__.py b/docs_src/extensions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/extensions/tutorial001.py b/docs_src/extensions/tutorial001.py new file mode 100644 index 0000000..1e5a1f9 --- /dev/null +++ b/docs_src/extensions/tutorial001.py @@ -0,0 +1,4 @@ +from mcp.server.apps import Apps +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("demo", extensions=[Apps()]) diff --git a/docs_src/extensions/tutorial002.py b/docs_src/extensions/tutorial002.py new file mode 100644 index 0000000..87b59bd --- /dev/null +++ b/docs_src/extensions/tutorial002.py @@ -0,0 +1,5 @@ +from mcp.server.extension import Extension + + +class Stamps(Extension): + identifier = "com.example/stamps" diff --git a/docs_src/extensions/tutorial003.py b/docs_src/extensions/tutorial003.py new file mode 100644 index 0000000..312371b --- /dev/null +++ b/docs_src/extensions/tutorial003.py @@ -0,0 +1,35 @@ +from collections.abc import Sequence +from typing import Any + +from mcp import Client +from mcp.server.extension import Extension, ToolBinding +from mcp.server.mcpserver import MCPServer + + +def stamp(text: str) -> str: + """Stamp a message with the office seal.""" + return f"[stamped] {text}" + + +class Stamps(Extension): + """A purely additive extension: one tool, one capability entry.""" + + identifier = "com.example/stamps" + + def settings(self) -> dict[str, Any]: + return {"sealed": True} + + def tools(self) -> Sequence[ToolBinding]: + return [ToolBinding(fn=stamp)] + + +mcp = MCPServer("post-office", extensions=[Stamps()]) + + +async def main() -> None: + async with Client(mcp) as client: + print(client.server_capabilities.extensions) + # {'com.example/stamps': {'sealed': True}} + result = await client.call_tool("stamp", {"text": "hello"}) + print(result.content) + # [TextContent(text='[stamped] hello')] diff --git a/docs_src/extensions/tutorial004.py b/docs_src/extensions/tutorial004.py new file mode 100644 index 0000000..7ad3205 --- /dev/null +++ b/docs_src/extensions/tutorial004.py @@ -0,0 +1,59 @@ +from collections.abc import Sequence +from typing import Any, Literal + +import mcp_types as types +from pydantic import Field + +from mcp import Client +from mcp.client import advertise +from mcp.server.context import ServerRequestContext +from mcp.server.extension import Extension, MethodBinding +from mcp.server.mcpserver import MCPServer, require_client_extension + +EXTENSION_ID = "com.example/search" + + +class SearchParams(types.RequestParams): + query: str + limit: int = Field(default=10, ge=1, le=100) + + +class SearchResult(types.Result): + items: list[str] + + +class SearchRequest(types.Request[SearchParams, Literal["com.example/search"]]): + method: Literal["com.example/search"] = "com.example/search" + params: SearchParams + + +async def search(ctx: ServerRequestContext[Any, Any], params: SearchParams) -> SearchResult: + require_client_extension(ctx, EXTENSION_ID) + return SearchResult(items=[f"{params.query}-{n}" for n in range(params.limit)]) + + +class Search(Extension): + """An extension that serves its own request method.""" + + identifier = EXTENSION_ID + + def methods(self) -> Sequence[MethodBinding]: + return [ + MethodBinding( + "com.example/search", + SearchParams, + search, + protocol_versions=frozenset({"2026-07-28"}), + ) + ] + + +mcp = MCPServer("catalog", extensions=[Search()]) + + +async def main() -> None: + async with Client(mcp, extensions=[advertise(EXTENSION_ID)]) as client: + request = SearchRequest(params=SearchParams(query="mcp", limit=3)) + result = await client.session.send_request(request, SearchResult) + print(result.items) + # ['mcp-0', 'mcp-1', 'mcp-2'] diff --git a/docs_src/extensions/tutorial005.py b/docs_src/extensions/tutorial005.py new file mode 100644 index 0000000..61ec6c7 --- /dev/null +++ b/docs_src/extensions/tutorial005.py @@ -0,0 +1,34 @@ +import logging +from typing import Any + +from mcp_types import CallToolRequestParams + +from mcp.server.context import CallNext, HandlerResult, ServerRequestContext +from mcp.server.extension import Extension +from mcp.server.mcpserver import MCPServer + +logger = logging.getLogger(__name__) + + +class AuditLog(Extension): + """Observe every tools/call without touching its result.""" + + identifier = "com.example/audit" + + async def intercept_tool_call( + self, + params: CallToolRequestParams, + ctx: ServerRequestContext[Any, Any], + call_next: CallNext, + ) -> HandlerResult: + logger.info("tool %r called", params.name) + return await call_next(ctx) + + +mcp = MCPServer("audited", extensions=[AuditLog()]) + + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b diff --git a/docs_src/extensions/tutorial006.py b/docs_src/extensions/tutorial006.py new file mode 100644 index 0000000..05ffbcb --- /dev/null +++ b/docs_src/extensions/tutorial006.py @@ -0,0 +1,70 @@ +from collections.abc import Sequence +from typing import Any, Literal + +import mcp_types as types + +from mcp import Client +from mcp.client import ClaimContext, ClientExtension, ResultClaim +from mcp.server.context import CallNext, HandlerResult, ServerRequestContext +from mcp.server.extension import Extension +from mcp.server.mcpserver import MCPServer, require_client_extension + +EXTENSION_ID = "com.example/receipts" + + +class ReceiptResult(types.Result): + """The claimed result shape; `result_type` pins the wire tag.""" + + result_type: Literal["receipt"] = "receipt" + receipt_token: str + + +class ReceiptIssuer(Extension): + """Server half: answers `buy` with a receipt instead of a final result.""" + + identifier = EXTENSION_ID + + async def intercept_tool_call( + self, + params: types.CallToolRequestParams, + ctx: ServerRequestContext[Any, Any], + call_next: CallNext, + ) -> HandlerResult: + if params.name != "buy": + return await call_next(ctx) + require_client_extension(ctx, EXTENSION_ID) + return {"resultType": "receipt", "receiptToken": "r-117"} + + +class Receipts(ClientExtension): + """Client half: claims the `receipt` shape and supplies the code that finishes it.""" + + identifier = EXTENSION_ID + + def claims(self) -> Sequence[ResultClaim[Any]]: + return [ResultClaim(result_type="receipt", model=ReceiptResult, resolve=self._redeem)] + + async def _redeem(self, claimed: ReceiptResult, ctx: ClaimContext) -> types.CallToolResult: + return await ctx.session.call_tool("redeem", {"token": claimed.receipt_token}) + + +mcp = MCPServer("shop", extensions=[ReceiptIssuer()]) + + +@mcp.tool() +def buy(item: str) -> types.CallToolResult: + """Buy an item.""" + raise NotImplementedError # ReceiptIssuer answers `buy` before the tool runs + + +@mcp.tool() +def redeem(token: str) -> str: + """Exchange a receipt token for the goods.""" + return f"goods for {token}" + + +async def main() -> None: + async with Client(mcp, extensions=[Receipts()]) as client: + result = await client.call_tool("buy", {"item": "lamp"}) + print(result.content) + # [TextContent(text='goods for r-117')] diff --git a/docs_src/extensions/tutorial007.py b/docs_src/extensions/tutorial007.py new file mode 100644 index 0000000..37706ca --- /dev/null +++ b/docs_src/extensions/tutorial007.py @@ -0,0 +1,50 @@ +from collections.abc import Sequence +from typing import Any, Literal + +import mcp_types as types + +from mcp import Client +from mcp.client import advertise +from mcp.server.context import ServerRequestContext +from mcp.server.extension import Extension, MethodBinding +from mcp.server.mcpserver import MCPServer + +EXTENSION_ID = "com.example/jobs" + + +class JobParams(types.RequestParams): + job_id: str + + +class JobStatus(types.Result): + status: str + + +class JobStatusRequest(types.Request[JobParams, Literal["com.example/jobs.status"]]): + method: Literal["com.example/jobs.status"] = "com.example/jobs.status" + params: JobParams + name_param = "jobId" # params["jobId"] rides the Mcp-Name header + + +async def job_status(ctx: ServerRequestContext[Any, Any], params: JobParams) -> JobStatus: + return JobStatus(status=f"{params.job_id} is running") + + +class Jobs(Extension): + """An extension whose verb names its subject, so the header can route on it.""" + + identifier = EXTENSION_ID + + def methods(self) -> Sequence[MethodBinding]: + return [MethodBinding("com.example/jobs.status", JobParams, job_status)] + + +mcp = MCPServer("worker", extensions=[Jobs()]) + + +async def main() -> None: + async with Client(mcp, extensions=[advertise(EXTENSION_ID)]) as client: + request = JobStatusRequest(params=JobParams(job_id="job-7")) + result = await client.session.send_request(request, JobStatus) + print(result.status) + # job-7 is running diff --git a/docs_src/first_steps/__init__.py b/docs_src/first_steps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/first_steps/tutorial001.py b/docs_src/first_steps/tutorial001.py new file mode 100644 index 0000000..7c3e7a5 --- /dev/null +++ b/docs_src/first_steps/tutorial001.py @@ -0,0 +1,21 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Demo") + + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + +@mcp.resource("greeting://{name}") +def greeting(name: str) -> str: + """Greet someone by name.""" + return f"Hello, {name}!" + + +@mcp.prompt() +def summarize(text: str) -> str: + """Summarize a piece of text in one sentence.""" + return f"Summarize the following text in one sentence:\n\n{text}" diff --git a/docs_src/handling_errors/__init__.py b/docs_src/handling_errors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/handling_errors/tutorial001.py b/docs_src/handling_errors/tutorial001.py new file mode 100644 index 0000000..003ea94 --- /dev/null +++ b/docs_src/handling_errors/tutorial001.py @@ -0,0 +1,13 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + +CATALOG = {"Dune": "Frank Herbert", "Neuromancer": "William Gibson"} + + +@mcp.tool() +def get_author(title: str) -> str: + """Look up the author of a book in the catalog.""" + if title not in CATALOG: + raise ValueError(f"No book titled {title!r} in the catalog.") + return CATALOG[title] diff --git a/docs_src/handling_errors/tutorial002.py b/docs_src/handling_errors/tutorial002.py new file mode 100644 index 0000000..b45c67e --- /dev/null +++ b/docs_src/handling_errors/tutorial002.py @@ -0,0 +1,16 @@ +from mcp_types import INVALID_PARAMS + +from mcp import MCPError +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + +CATALOG = {"Dune": "Frank Herbert", "Neuromancer": "William Gibson"} + + +@mcp.tool() +def get_author(title: str) -> str: + """Look up the author of a book in the catalog.""" + if title not in CATALOG: + raise MCPError(code=INVALID_PARAMS, message=f"No book titled {title!r} in the catalog.") + return CATALOG[title] diff --git a/docs_src/handling_errors/tutorial003.py b/docs_src/handling_errors/tutorial003.py new file mode 100644 index 0000000..55f2c4f --- /dev/null +++ b/docs_src/handling_errors/tutorial003.py @@ -0,0 +1,14 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver.exceptions import ResourceNotFoundError + +mcp = MCPServer("Bookshop") + +CATALOG = {"Dune": "Frank Herbert", "Neuromancer": "William Gibson"} + + +@mcp.resource("books://{title}") +def book(title: str) -> str: + """The catalog entry for one book.""" + if title not in CATALOG: + raise ResourceNotFoundError(f"No book titled {title!r} in the catalog.") + return f"{title} by {CATALOG[title]}" diff --git a/docs_src/identity_assertion/__init__.py b/docs_src/identity_assertion/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/identity_assertion/tutorial001.py b/docs_src/identity_assertion/tutorial001.py new file mode 100644 index 0000000..8a7e9a0 --- /dev/null +++ b/docs_src/identity_assertion/tutorial001.py @@ -0,0 +1,69 @@ +import time +import uuid + +import httpx +import jwt + +from mcp import Client +from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken + +IDP_SIGNING_KEY = "the-enterprise-idp-signing-key" + + +class InMemoryTokenStorage: + def __init__(self) -> None: + self.tokens: OAuthToken | None = None + self.client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self.tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + self.tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self.client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self.client_info = client_info + + +def idp_issue_id_jag(subject: str, audience: str, resource: str) -> str: + now = int(time.time()) + claims = { + "iss": "https://idp.example.com", + "sub": subject, + "aud": audience, + "client_id": "finance-agent", + "resource": resource, + "scope": "notes:read", + "jti": str(uuid.uuid4()), + "iat": now, + "exp": now + 300, + } + return jwt.encode(claims, IDP_SIGNING_KEY, algorithm="HS256", headers={"typ": "oauth-id-jag+jwt"}) + + +async def fetch_id_jag(audience: str, resource: str) -> str: + return idp_issue_id_jag("alice@example.com", audience, resource) + + +oauth = IdentityAssertionOAuthProvider( + server_url="http://localhost:8001/mcp", + storage=InMemoryTokenStorage(), + client_id="finance-agent", + client_secret="finance-agent-secret", + issuer="https://auth.example.com/", + assertion_provider=fetch_id_jag, + scope="notes:read", +) + + +async def main() -> None: + async with httpx.AsyncClient(auth=oauth, follow_redirects=True) as http_client: + transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client) + async with Client(transport) as client: + result = await client.list_tools() + print([tool.name for tool in result.tools]) diff --git a/docs_src/identity_assertion/tutorial002.py b/docs_src/identity_assertion/tutorial002.py new file mode 100644 index 0000000..d537069 --- /dev/null +++ b/docs_src/identity_assertion/tutorial002.py @@ -0,0 +1,107 @@ +import secrets +import time + +import jwt +from pydantic import AnyHttpUrl +from starlette.applications import Starlette + +from mcp.server.auth.provider import ( + AccessToken, + AuthorizationCode, + AuthorizationParams, + AuthorizeError, + IdentityAssertionParams, + OAuthAuthorizationServerProvider, + RefreshToken, + TokenError, +) +from mcp.server.auth.routes import create_auth_routes +from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken + +ISSUER = "https://auth.example.com/" +MCP_SERVER = "http://localhost:8001/mcp" +IDP_ISSUER = "https://idp.example.com" +IDP_SIGNING_KEY = "the-enterprise-idp-signing-key" + +REGISTERED_CLIENTS = { + "finance-agent": OAuthClientInformationFull( + client_id="finance-agent", + client_secret="finance-agent-secret", + redirect_uris=None, + grant_types=[JWT_BEARER_GRANT_TYPE], + token_endpoint_auth_method="client_secret_post", + ) +} + + +class EnterpriseAuthorizationServer(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]): + def __init__(self) -> None: + self.access_tokens: dict[str, AccessToken] = {} + self.seen_jtis: set[str] = set() + + async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: + return REGISTERED_CLIENTS.get(client_id) + + async def load_access_token(self, token: str) -> AccessToken | None: + return self.access_tokens.get(token) + + async def exchange_identity_assertion( + self, client: OAuthClientInformationFull, params: IdentityAssertionParams + ) -> OAuthToken: + try: + header = jwt.get_unverified_header(params.assertion) + claims = jwt.decode( + params.assertion, + IDP_SIGNING_KEY, + algorithms=["HS256"], + issuer=IDP_ISSUER, + audience=ISSUER, + options={"require": ["iss", "sub", "aud", "exp", "iat", "jti", "client_id", "resource", "scope"]}, + ) + except jwt.InvalidTokenError as error: + raise TokenError("invalid_grant", "the assertion did not verify") from error + if header.get("typ") != "oauth-id-jag+jwt": + raise TokenError("invalid_grant", "the assertion is not an ID-JAG") + if claims["client_id"] != client.client_id: + raise TokenError("invalid_grant", "the assertion was issued to a different client") + if claims["resource"] != MCP_SERVER: + raise TokenError("invalid_target", "the assertion is for a resource this server does not serve") + if claims["jti"] in self.seen_jtis: + raise TokenError("invalid_grant", "the assertion has already been used") + self.seen_jtis.add(claims["jti"]) + scopes = claims["scope"].split() + access_token = f"mcp_{secrets.token_hex(16)}" + self.access_tokens[access_token] = AccessToken( + token=access_token, + client_id=claims["client_id"], + scopes=scopes, + expires_at=int(time.time()) + 300, + resource=claims["resource"], + subject=claims["sub"], + ) + return OAuthToken(access_token=access_token, token_type="Bearer", expires_in=300, scope=" ".join(scopes)) + + async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: + raise AuthorizeError("unauthorized_client", "this authorization server only accepts ID-JAGs") + + async def load_authorization_code(self, client: OAuthClientInformationFull, authorization_code: str) -> None: + return None + + async def exchange_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode + ) -> OAuthToken: + raise TokenError("invalid_grant", "this authorization server only accepts ID-JAGs") + + async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> None: + return None + + async def exchange_refresh_token( + self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str] + ) -> OAuthToken: + raise TokenError("invalid_grant", "this authorization server only accepts ID-JAGs") + + +provider = EnterpriseAuthorizationServer() +auth_app = Starlette( + routes=create_auth_routes(provider, issuer_url=AnyHttpUrl(ISSUER), identity_assertion_enabled=True) +) diff --git a/docs_src/index/__init__.py b/docs_src/index/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/index/tutorial001.py b/docs_src/index/tutorial001.py new file mode 100644 index 0000000..d975940 --- /dev/null +++ b/docs_src/index/tutorial001.py @@ -0,0 +1,15 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Demo") + + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + +@mcp.resource("greeting://{name}") +def greeting(name: str) -> str: + """Greet someone by name.""" + return f"Hello, {name}!" diff --git a/docs_src/legacy_clients/__init__.py b/docs_src/legacy_clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/legacy_clients/tutorial001.py b/docs_src/legacy_clients/tutorial001.py new file mode 100644 index 0000000..2f8b119 --- /dev/null +++ b/docs_src/legacy_clients/tutorial001.py @@ -0,0 +1,42 @@ +from typing import Annotated + +from mcp_types import ElicitRequestParams, ElicitResult +from pydantic import BaseModel + +from mcp import Client +from mcp.client import ClientRequestContext +from mcp.server import MCPServer +from mcp.server.mcpserver import AcceptedElicitation, Elicit, ElicitationResult, Resolve + +mcp = MCPServer("Bookshop") + + +class Quantity(BaseModel): + copies: int + + +async def ask_quantity() -> Elicit[Quantity]: + """Resolver: ask the user how many copies to put aside.""" + return Elicit("How many copies?", Quantity) + + +@mcp.tool() +async def reserve(title: str, quantity: Annotated[ElicitationResult[Quantity], Resolve(ask_quantity)]) -> str: + """Reserve copies of a book, asking the user how many.""" + if isinstance(quantity, AcceptedElicitation): + return f"Reserved {quantity.data.copies} of {title!r}." + return "Nothing reserved." + + +async def answer(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + return ElicitResult(action="accept", content={"copies": 2}) + + +async def main() -> None: + async with ( + Client(mcp, mode="legacy", elicitation_callback=answer) as legacy, + Client(mcp, elicitation_callback=answer) as modern, + ): + for client in (legacy, modern): + result = await client.call_tool("reserve", {"title": "Dune"}) + print(client.protocol_version, result.structured_content) diff --git a/docs_src/legacy_clients/tutorial002.py b/docs_src/legacy_clients/tutorial002.py new file mode 100644 index 0000000..53c6475 --- /dev/null +++ b/docs_src/legacy_clients/tutorial002.py @@ -0,0 +1,28 @@ +from typing import Annotated + +from pydantic import BaseModel + +from mcp.server import MCPServer +from mcp.server.mcpserver import AcceptedElicitation, Elicit, ElicitationResult, Resolve + +mcp = MCPServer("Bookshop") + + +class Quantity(BaseModel): + copies: int + + +async def ask_quantity() -> Elicit[Quantity]: + """Resolver: ask the user how many copies to put aside.""" + return Elicit("How many copies?", Quantity) + + +@mcp.tool() +async def reserve(title: str, quantity: Annotated[ElicitationResult[Quantity], Resolve(ask_quantity)]) -> str: + """Reserve copies of a book, asking the user how many.""" + if isinstance(quantity, AcceptedElicitation): + return f"Reserved {quantity.data.copies} of {title!r}." + return "Nothing reserved." + + +app = mcp.streamable_http_app(stateless_http=True) diff --git a/docs_src/legacy_clients/tutorial003.py b/docs_src/legacy_clients/tutorial003.py new file mode 100644 index 0000000..52f8f1e --- /dev/null +++ b/docs_src/legacy_clients/tutorial003.py @@ -0,0 +1,21 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Bookshop") + +STOCK = {"Dune": 3} + + +@mcp.resource("stock://{title}") +def stock(title: str) -> str: + """How many copies of one book are on the shelf.""" + return f"{STOCK[title]} in stock" + + +@mcp.tool() +async def restock(title: str, copies: int, ctx: Context) -> str: + """Put copies of a book back on the shelf.""" + STOCK[title] = STOCK.get(title, 0) + copies + await ctx.notify_resource_updated(f"stock://{title}") + await ctx.session.send_resource_updated(f"stock://{title}") + return f"{STOCK[title]} in stock" diff --git a/docs_src/lifespan/__init__.py b/docs_src/lifespan/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/lifespan/tutorial001.py b/docs_src/lifespan/tutorial001.py new file mode 100644 index 0000000..9b7ba3d --- /dev/null +++ b/docs_src/lifespan/tutorial001.py @@ -0,0 +1,41 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + + +class Database: + @classmethod + async def connect(cls) -> "Database": + return cls() + + async def disconnect(self) -> None: ... + + def query(self) -> int: + return 3 + + +@dataclass +class AppContext: + db: Database + + +@asynccontextmanager +async def app_lifespan(server: MCPServer) -> AsyncIterator[AppContext]: + db = await Database.connect() + try: + yield AppContext(db=db) + finally: + await db.disconnect() + + +mcp = MCPServer("Bookshop", lifespan=app_lifespan) + + +@mcp.tool() +def count_books(genre: str, ctx: Context[AppContext]) -> str: + """Count the books in a genre.""" + db = ctx.request_context.lifespan_context.db + return f"{db.query()} books in {genre!r}." diff --git a/docs_src/lifespan/tutorial002.py b/docs_src/lifespan/tutorial002.py new file mode 100644 index 0000000..6ed3c6a --- /dev/null +++ b/docs_src/lifespan/tutorial002.py @@ -0,0 +1,44 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + + +class Database: + def __init__(self) -> None: + self.connected = False + + async def connect(self) -> None: + self.connected = True + + async def disconnect(self) -> None: + self.connected = False + + +@dataclass +class AppContext: + db: Database + + +database = Database() + + +@asynccontextmanager +async def app_lifespan(server: MCPServer) -> AsyncIterator[AppContext]: + await database.connect() + try: + yield AppContext(db=database) + finally: + await database.disconnect() + + +mcp = MCPServer("Bookshop", lifespan=app_lifespan) + + +@mcp.tool() +def database_status(ctx: Context[AppContext]) -> str: + """Report whether the database connection is up.""" + db = ctx.request_context.lifespan_context.db + return "connected" if db.connected else "disconnected" diff --git a/docs_src/logging/__init__.py b/docs_src/logging/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/logging/tutorial001.py b/docs_src/logging/tutorial001.py new file mode 100644 index 0000000..ee90d22 --- /dev/null +++ b/docs_src/logging/tutorial001.py @@ -0,0 +1,14 @@ +import logging + +from mcp.server import MCPServer + +logger = logging.getLogger(__name__) + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + logger.info("Searching for %r", query) + return f"Found 3 books matching {query!r}." diff --git a/docs_src/lowlevel/__init__.py b/docs_src/lowlevel/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/lowlevel/tutorial001.py b/docs_src/lowlevel/tutorial001.py new file mode 100644 index 0000000..999c707 --- /dev/null +++ b/docs_src/lowlevel/tutorial001.py @@ -0,0 +1,33 @@ +from mcp_types import ( + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) + +from mcp.server import Server, ServerRequestContext + +SEARCH_BOOKS = Tool( + name="search_books", + description="Search the catalog by title or author.", + input_schema={ + "type": "object", + "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}}, + "required": ["query", "limit"], + }, +) + + +async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[SEARCH_BOOKS]) + + +async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + args = params.arguments or {} + text = f"Found 3 books matching {args['query']!r} (showing up to {args['limit']})." + return CallToolResult(content=[TextContent(type="text", text=text)]) + + +server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool) diff --git a/docs_src/lowlevel/tutorial002.py b/docs_src/lowlevel/tutorial002.py new file mode 100644 index 0000000..d3033f6 --- /dev/null +++ b/docs_src/lowlevel/tutorial002.py @@ -0,0 +1,48 @@ +from mcp_types import ( + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) + +from mcp.server import Server, ServerRequestContext + +SEARCH_BOOKS = Tool( + name="search_books", + description="Search the catalog by title or author.", + input_schema={ + "type": "object", + "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}}, + "required": ["query", "limit"], + }, +) + +ADD_BOOK = Tool( + name="add_book", + description="Add a book to the catalog.", + input_schema={ + "type": "object", + "properties": {"title": {"type": "string"}, "author": {"type": "string"}, "year": {"type": "integer"}}, + "required": ["title", "author", "year"], + }, +) + + +async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[SEARCH_BOOKS, ADD_BOOK]) + + +async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + args = params.arguments or {} + if params.name == "search_books": + text = f"Found 3 books matching {args['query']!r} (showing up to {args['limit']})." + elif params.name == "add_book": + text = f"Added {args['title']!r} by {args['author']} ({args['year']})." + else: + raise ValueError(f"Unknown tool: {params.name}") + return CallToolResult(content=[TextContent(type="text", text=text)]) + + +server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool) diff --git a/docs_src/lowlevel/tutorial003.py b/docs_src/lowlevel/tutorial003.py new file mode 100644 index 0000000..f350397 --- /dev/null +++ b/docs_src/lowlevel/tutorial003.py @@ -0,0 +1,41 @@ +from mcp_types import ( + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) + +from mcp.server import Server, ServerRequestContext + +SEARCH_BOOKS = Tool( + name="search_books", + description="Search the catalog by title or author.", + input_schema={ + "type": "object", + "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}}, + "required": ["query", "limit"], + }, + output_schema={ + "type": "object", + "properties": {"matches": {"type": "integer"}, "query": {"type": "string"}}, + "required": ["matches", "query"], + }, +) + + +async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[SEARCH_BOOKS]) + + +async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + args = params.arguments or {} + data = {"matches": 3, "query": args["query"]} + return CallToolResult( + content=[TextContent(type="text", text=f"Found 3 books matching {args['query']!r}.")], + structured_content=data, + ) + + +server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool) diff --git a/docs_src/lowlevel/tutorial004.py b/docs_src/lowlevel/tutorial004.py new file mode 100644 index 0000000..18b0bef --- /dev/null +++ b/docs_src/lowlevel/tutorial004.py @@ -0,0 +1,42 @@ +from mcp_types import ( + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) + +from mcp.server import Server, ServerRequestContext + +SEARCH_BOOKS = Tool( + name="search_books", + description="Search the catalog by title or author.", + input_schema={ + "type": "object", + "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}}, + "required": ["query", "limit"], + }, + output_schema={ + "type": "object", + "properties": {"matches": {"type": "integer"}, "query": {"type": "string"}}, + "required": ["matches", "query"], + }, +) + + +async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[SEARCH_BOOKS]) + + +async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + args = params.arguments or {} + data = {"matches": 3, "query": args["query"]} + return CallToolResult( + content=[TextContent(type="text", text=f"Found 3 books matching {args['query']!r}.")], + structured_content=data, + _meta={"bookshop/record_ids": ["bk_17", "bk_42", "bk_99"]}, + ) + + +server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool) diff --git a/docs_src/lowlevel/tutorial005.py b/docs_src/lowlevel/tutorial005.py new file mode 100644 index 0000000..e33077e --- /dev/null +++ b/docs_src/lowlevel/tutorial005.py @@ -0,0 +1,51 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from mcp_types import ( + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) + +from mcp.server import Server, ServerRequestContext + + +@dataclass +class Catalog: + books: list[str] + + def search(self, query: str) -> list[str]: + return [title for title in self.books if query.lower() in title.lower()] + + +@asynccontextmanager +async def lifespan(server: Server[Catalog]) -> AsyncIterator[Catalog]: + yield Catalog(books=["Dune", "Dune Messiah", "Children of Dune"]) + + +SEARCH_BOOKS = Tool( + name="search_books", + description="Search the catalog by title or author.", + input_schema={ + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, +) + + +async def list_tools(ctx: ServerRequestContext[Catalog], params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[SEARCH_BOOKS]) + + +async def call_tool(ctx: ServerRequestContext[Catalog], params: CallToolRequestParams) -> CallToolResult: + matches = ctx.lifespan_context.search((params.arguments or {})["query"]) + text = f"Found {len(matches)} books: {', '.join(matches)}." + return CallToolResult(content=[TextContent(type="text", text=text)]) + + +server = Server("Bookshop", lifespan=lifespan, on_list_tools=list_tools, on_call_tool=call_tool) diff --git a/docs_src/lowlevel/tutorial006.py b/docs_src/lowlevel/tutorial006.py new file mode 100644 index 0000000..601fe5c --- /dev/null +++ b/docs_src/lowlevel/tutorial006.py @@ -0,0 +1,48 @@ +from mcp_types import ( + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + RequestParams, + TextContent, + Tool, +) +from pydantic import BaseModel + +from mcp.server import Server, ServerRequestContext + +SEARCH_BOOKS = Tool( + name="search_books", + description="Search the catalog by title or author.", + input_schema={ + "type": "object", + "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}}, + "required": ["query", "limit"], + }, +) + + +async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[SEARCH_BOOKS]) + + +async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + args = params.arguments or {} + text = f"Found 3 books matching {args['query']!r} (showing up to {args['limit']})." + return CallToolResult(content=[TextContent(type="text", text=text)]) + + +class ReindexParams(RequestParams): + full: bool = False + + +class ReindexResult(BaseModel): + indexed: int + + +async def reindex(ctx: ServerRequestContext, params: ReindexParams) -> ReindexResult: + return ReindexResult(indexed=3) + + +server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool) +server.add_request_handler("bookshop/reindex", ReindexParams, reindex) diff --git a/docs_src/media/__init__.py b/docs_src/media/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/media/tutorial001.py b/docs_src/media/tutorial001.py new file mode 100644 index 0000000..646817f --- /dev/null +++ b/docs_src/media/tutorial001.py @@ -0,0 +1,16 @@ +import base64 + +from mcp.server import MCPServer +from mcp.server.mcpserver import Image + +mcp = MCPServer("Brand kit") + +LOGO_PNG = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGOQ9bsBAAHPAURf8l/aAAAAAElFTkSuQmCC" +) + + +@mcp.tool() +def logo() -> Image: + """The brand logo as a PNG.""" + return Image(data=LOGO_PNG, format="png") diff --git a/docs_src/media/tutorial002.py b/docs_src/media/tutorial002.py new file mode 100644 index 0000000..c98bddc --- /dev/null +++ b/docs_src/media/tutorial002.py @@ -0,0 +1,24 @@ +import base64 + +from mcp.server import MCPServer +from mcp.server.mcpserver import Audio, Image + +mcp = MCPServer("Brand kit") + +LOGO_PNG = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGOQ9bsBAAHPAURf8l/aAAAAAElFTkSuQmCC" +) + +CHIME_WAV = base64.b64decode("UklGRjQAAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YRAAAAAAAAAAAAAAAAAAAAAAAAAA") + + +@mcp.tool() +def logo() -> Image: + """The brand logo as a PNG.""" + return Image(data=LOGO_PNG, format="png") + + +@mcp.tool() +def chime() -> Audio: + """The notification chime as a WAV.""" + return Audio(data=CHIME_WAV, format="wav") diff --git a/docs_src/media/tutorial003.py b/docs_src/media/tutorial003.py new file mode 100644 index 0000000..a06e6df --- /dev/null +++ b/docs_src/media/tutorial003.py @@ -0,0 +1,20 @@ +from mcp_types import Icon + +from mcp.server import MCPServer + +LOGO = Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"]) +PALETTE = Icon(src="https://example.com/palette.svg", mime_type="image/svg+xml", sizes=["any"]) + +mcp = MCPServer("Brand kit", icons=[LOGO]) + + +@mcp.tool(icons=[PALETTE]) +def palette() -> list[str]: + """The brand colour palette as hex codes.""" + return ["#1d4ed8", "#f59e0b", "#10b981"] + + +@mcp.resource("brand://guidelines", icons=[LOGO]) +def guidelines() -> str: + """How to use the brand assets.""" + return "Use the primary colour for calls to action." diff --git a/docs_src/middleware/__init__.py b/docs_src/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/middleware/tutorial001.py b/docs_src/middleware/tutorial001.py new file mode 100644 index 0000000..71be62d --- /dev/null +++ b/docs_src/middleware/tutorial001.py @@ -0,0 +1,50 @@ +import logging +import time + +from mcp_types import ( + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) + +from mcp.server import Server, ServerRequestContext +from mcp.server.context import CallNext, HandlerResult + +logger = logging.getLogger(__name__) + + +async def on_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult( + tools=[ + Tool( + name="search_books", + description="Search the catalog by title or author.", + input_schema={ + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + ) + ] + ) + + +async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + query = (params.arguments or {})["query"] + return CallToolResult(content=[TextContent(type="text", text=f"Found 3 books matching {query!r}.")]) + + +async def log_timing(ctx: ServerRequestContext, call_next: CallNext) -> HandlerResult: + start = time.perf_counter() + try: + return await call_next(ctx) + finally: + elapsed_ms = (time.perf_counter() - start) * 1000 + logger.info("%s took %.1f ms", ctx.method, elapsed_ms) + + +server = Server("Bookshop", on_list_tools=on_list_tools, on_call_tool=on_call_tool) +server.middleware.append(log_timing) diff --git a/docs_src/mrtr/__init__.py b/docs_src/mrtr/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/mrtr/tutorial001.py b/docs_src/mrtr/tutorial001.py new file mode 100644 index 0000000..c0f4153 --- /dev/null +++ b/docs_src/mrtr/tutorial001.py @@ -0,0 +1,53 @@ +from mcp_types import ( + CallToolRequestParams, + CallToolResult, + ElicitRequest, + ElicitRequestFormParams, + ElicitResult, + InputRequiredResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) + +from mcp.server import Server, ServerRequestContext + +ASK_REGION = ElicitRequest( + params=ElicitRequestFormParams( + message="Which region should the database live in?", + requested_schema={ + "type": "object", + "properties": {"region": {"type": "string"}}, + "required": ["region"], + }, + ) +) + + +async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult( + tools=[ + Tool( + name="provision", + description="Provision a database. Asks which region to put it in.", + input_schema={ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + }, + ) + ] + ) + + +async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult | InputRequiredResult: + answer = (params.input_responses or {}).get("region") + if not isinstance(answer, ElicitResult) or answer.content is None: + return InputRequiredResult(input_requests={"region": ASK_REGION}, request_state="provision-v1") + name = (params.arguments or {})["name"] + text = f"Provisioned {name!r} in {answer.content['region']}." + return CallToolResult(content=[TextContent(type="text", text=text)]) + + +server = Server("Provisioner", on_list_tools=list_tools, on_call_tool=call_tool) diff --git a/docs_src/mrtr/tutorial002.py b/docs_src/mrtr/tutorial002.py new file mode 100644 index 0000000..0a14021 --- /dev/null +++ b/docs_src/mrtr/tutorial002.py @@ -0,0 +1,23 @@ +from mcp_types import CallToolResult, ElicitRequest, ElicitResult, InputRequest, InputRequiredResult, InputResponse + +from mcp import Client + + +def fulfil(request: InputRequest) -> InputResponse: + if not isinstance(request, ElicitRequest): + raise NotImplementedError(f"this client cannot answer a {request.method!r} request") + return ElicitResult(action="accept", content={"region": "eu-west-1"}) + + +async def provision(client: Client, name: str) -> CallToolResult: + result = await client.session.call_tool("provision", {"name": name}, allow_input_required=True) + while isinstance(result, InputRequiredResult): + responses = {key: fulfil(request) for key, request in (result.input_requests or {}).items()} + result = await client.session.call_tool( + "provision", + {"name": name}, + input_responses=responses, + request_state=result.request_state, + allow_input_required=True, + ) + return result diff --git a/docs_src/mrtr/tutorial003.py b/docs_src/mrtr/tutorial003.py new file mode 100644 index 0000000..03eb6bf --- /dev/null +++ b/docs_src/mrtr/tutorial003.py @@ -0,0 +1,14 @@ +from mcp_types import ElicitRequestParams, ElicitResult + +from mcp import Client +from mcp.client import ClientRequestContext + + +async def handle_elicitation(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + return ElicitResult(action="accept", content={"region": "eu-west-1"}) + + +async def main() -> None: + async with Client("http://127.0.0.1:8000/mcp", elicitation_callback=handle_elicitation) as client: + result = await client.call_tool("provision", {"name": "orders"}) + print(result.content) diff --git a/docs_src/mrtr/tutorial004.py b/docs_src/mrtr/tutorial004.py new file mode 100644 index 0000000..05b9459 --- /dev/null +++ b/docs_src/mrtr/tutorial004.py @@ -0,0 +1,26 @@ +from mcp_types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult + +from mcp.server.mcpserver import Context, MCPServer +from mcp.server.mcpserver.prompts.base import UserMessage + +mcp = MCPServer("Briefing") + +ASK_AUDIENCE = ElicitRequest( + params=ElicitRequestFormParams( + message="Who is the briefing for?", + requested_schema={ + "type": "object", + "properties": {"audience": {"type": "string"}}, + "required": ["audience"], + }, + ) +) + + +@mcp.prompt() +async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult: + """Draft a briefing tuned to its audience.""" + answer = (ctx.input_responses or {}).get("audience") + if not isinstance(answer, ElicitResult) or answer.content is None: + return InputRequiredResult(input_requests={"audience": ASK_AUDIENCE}) + return [UserMessage(f"Write a briefing for {answer.content['audience']}.")] diff --git a/docs_src/mrtr/tutorial005.py b/docs_src/mrtr/tutorial005.py new file mode 100644 index 0000000..a8588b2 --- /dev/null +++ b/docs_src/mrtr/tutorial005.py @@ -0,0 +1,38 @@ +import os + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +from mcp.server import MCPServer +from mcp.server.mcpserver import InvalidRequestState, RequestStateSecurity + +PREFIX = "kms1." # format version; fed to GCM as associated data, so it is bound under the tag + + +def unwrap_data_key() -> bytes: + """One KMS call at process start, kms.decrypt(CiphertextBlob=...); every token after that is local crypto.""" + return os.urandom(32) # stand-in for the unwrapped 32-byte data key + + +class EnvelopeCodec: + def __init__(self, data_key: bytes) -> None: + self._aesgcm = AESGCM(data_key) + + def seal(self, payload: bytes) -> str: + nonce = os.urandom(12) + return PREFIX + (nonce + self._aesgcm.encrypt(nonce, payload, PREFIX.encode())).hex() + + def unseal(self, token: str) -> bytes: + if not token.startswith(PREFIX): + raise InvalidRequestState("unknown token format") + body = token[len(PREFIX) :] + try: + raw = bytes.fromhex(body) + if raw.hex() != body: # only the exact string seal() produced verifies + raise ValueError("non-canonical hex") + return self._aesgcm.decrypt(raw[:12], raw[12:], PREFIX.encode()) + except (ValueError, InvalidTag) as exc: + raise InvalidRequestState("token failed verification") from exc + + +mcp = MCPServer("Deployer", request_state_security=RequestStateSecurity(codec=EnvelopeCodec(unwrap_data_key()))) diff --git a/docs_src/oauth_clients/__init__.py b/docs_src/oauth_clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/oauth_clients/tutorial001.py b/docs_src/oauth_clients/tutorial001.py new file mode 100644 index 0000000..4360379 --- /dev/null +++ b/docs_src/oauth_clients/tutorial001.py @@ -0,0 +1,62 @@ +from urllib.parse import parse_qs, urlparse + +import httpx +from pydantic import AnyUrl + +from mcp import Client +from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken + + +class InMemoryTokenStorage: + def __init__(self) -> None: + self.tokens: OAuthToken | None = None + self.client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self.tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + self.tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self.client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self.client_info = client_info + + +async def open_browser(authorization_url: str) -> None: + print(f"Visit: {authorization_url}") + + +async def wait_for_callback() -> AuthorizationCodeResult: + redirect_url = input("Paste the URL you were redirected to: ") + params = parse_qs(urlparse(redirect_url).query) + return AuthorizationCodeResult( + code=params["code"][0], + state=params["state"][0], + iss=params["iss"][0] if "iss" in params else None, + ) + + +oauth = OAuthClientProvider( + server_url="http://localhost:8001/mcp", + client_metadata=OAuthClientMetadata( + client_name="Bookshop Agent", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + scope="user", + ), + storage=InMemoryTokenStorage(), + redirect_handler=open_browser, + callback_handler=wait_for_callback, +) + + +async def main() -> None: + async with httpx.AsyncClient(auth=oauth, follow_redirects=True) as http_client: + transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client) + async with Client(transport) as client: + result = await client.list_tools() + print([tool.name for tool in result.tools]) diff --git a/docs_src/oauth_clients/tutorial002.py b/docs_src/oauth_clients/tutorial002.py new file mode 100644 index 0000000..b5b052c --- /dev/null +++ b/docs_src/oauth_clients/tutorial002.py @@ -0,0 +1,41 @@ +import httpx + +from mcp import Client +from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken + + +class InMemoryTokenStorage: + def __init__(self) -> None: + self.tokens: OAuthToken | None = None + self.client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self.tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + self.tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self.client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self.client_info = client_info + + +oauth = ClientCredentialsOAuthProvider( + server_url="http://localhost:8001/mcp", + storage=InMemoryTokenStorage(), + client_id="reporting-agent", + client_secret="...", + scopes="user", +) + + +async def main() -> None: + async with httpx.AsyncClient(auth=oauth, follow_redirects=True) as http_client: + transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client) + async with Client(transport) as client: + result = await client.list_tools() + print([tool.name for tool in result.tools]) diff --git a/docs_src/opentelemetry/__init__.py b/docs_src/opentelemetry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/opentelemetry/tutorial001.py b/docs_src/opentelemetry/tutorial001.py new file mode 100644 index 0000000..3e66e90 --- /dev/null +++ b/docs_src/opentelemetry/tutorial001.py @@ -0,0 +1,9 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." diff --git a/docs_src/pagination/__init__.py b/docs_src/pagination/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/pagination/tutorial001.py b/docs_src/pagination/tutorial001.py new file mode 100644 index 0000000..2ad4b94 --- /dev/null +++ b/docs_src/pagination/tutorial001.py @@ -0,0 +1,20 @@ +from typing import Any + +from mcp_types import ListResourcesResult, PaginatedRequestParams, Resource + +from mcp.server import Server, ServerRequestContext + +BOOKS = [f"book-{n}" for n in range(1, 101)] + +PAGE_SIZE = 10 + + +async def list_books(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListResourcesResult: + start = 0 if params is None or params.cursor is None else int(params.cursor) + end = start + PAGE_SIZE + page = [Resource(uri=f"books://catalog/{name}", name=name) for name in BOOKS[start:end]] + next_cursor = str(end) if end < len(BOOKS) else None + return ListResourcesResult(resources=page, next_cursor=next_cursor) + + +server = Server("Bookshop", on_list_resources=list_books) diff --git a/docs_src/pagination/tutorial002.py b/docs_src/pagination/tutorial002.py new file mode 100644 index 0000000..cacb796 --- /dev/null +++ b/docs_src/pagination/tutorial002.py @@ -0,0 +1,34 @@ +from typing import Any + +from mcp_types import ListResourcesResult, PaginatedRequestParams, Resource + +from mcp import Client +from mcp.server import Server, ServerRequestContext + +BOOKS = [f"book-{n}" for n in range(1, 101)] + +PAGE_SIZE = 10 + + +async def list_books(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListResourcesResult: + start = 0 if params is None or params.cursor is None else int(params.cursor) + end = start + PAGE_SIZE + page = [Resource(uri=f"books://catalog/{name}", name=name) for name in BOOKS[start:end]] + next_cursor = str(end) if end < len(BOOKS) else None + return ListResourcesResult(resources=page, next_cursor=next_cursor) + + +server = Server("Bookshop", on_list_resources=list_books) + + +async def main() -> None: + async with Client(server) as client: + resources: list[Resource] = [] + cursor: str | None = None + while True: + page = await client.list_resources(cursor=cursor) + resources.extend(page.resources) + if page.next_cursor is None: + break + cursor = page.next_cursor + print(f"{len(resources)} resources") diff --git a/docs_src/progress/__init__.py b/docs_src/progress/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/progress/tutorial001.py b/docs_src/progress/tutorial001.py new file mode 100644 index 0000000..afa9d49 --- /dev/null +++ b/docs_src/progress/tutorial001.py @@ -0,0 +1,12 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +async def import_catalog(urls: list[str], ctx: Context) -> str: + """Import book records from a list of catalog URLs.""" + for done, url in enumerate(urls, start=1): + await ctx.report_progress(done, total=len(urls), message=f"Imported {url}") + return f"Imported {len(urls)} records." diff --git a/docs_src/progress/tutorial002.py b/docs_src/progress/tutorial002.py new file mode 100644 index 0000000..270d9fc --- /dev/null +++ b/docs_src/progress/tutorial002.py @@ -0,0 +1,21 @@ +from collections.abc import AsyncIterator + +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Bookshop") + + +async def fetch_records(feed_url: str) -> AsyncIterator[str]: + for title in ("Dune", "Neuromancer", "Hyperion"): + yield f"{feed_url}#{title}" + + +@mcp.tool() +async def import_feed(feed_url: str, ctx: Context) -> str: + """Import every record a catalog feed yields.""" + imported = 0 + async for record in fetch_records(feed_url): + imported += 1 + await ctx.report_progress(imported, message=f"Imported {record}") + return f"Imported {imported} records." diff --git a/docs_src/prompts/__init__.py b/docs_src/prompts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/prompts/tutorial001.py b/docs_src/prompts/tutorial001.py new file mode 100644 index 0000000..8ae5042 --- /dev/null +++ b/docs_src/prompts/tutorial001.py @@ -0,0 +1,9 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Code Helper") + + +@mcp.prompt() +def review_code(code: str) -> str: + """Review a piece of code.""" + return f"Please review this code:\n\n{code}" diff --git a/docs_src/prompts/tutorial002.py b/docs_src/prompts/tutorial002.py new file mode 100644 index 0000000..3db7862 --- /dev/null +++ b/docs_src/prompts/tutorial002.py @@ -0,0 +1,20 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, UserMessage + +mcp = MCPServer("Code Helper") + + +@mcp.prompt() +def review_code(code: str) -> str: + """Review a piece of code.""" + return f"Please review this code:\n\n{code}" + + +@mcp.prompt() +def debug_error(error: str) -> list[Message]: + """Start a debugging conversation.""" + return [ + UserMessage("I'm seeing this error:"), + UserMessage(error), + AssistantMessage("I'll help debug that. What have you tried so far?"), + ] diff --git a/docs_src/prompts/tutorial003.py b/docs_src/prompts/tutorial003.py new file mode 100644 index 0000000..63600c6 --- /dev/null +++ b/docs_src/prompts/tutorial003.py @@ -0,0 +1,16 @@ +from typing import Annotated + +from pydantic import Field + +from mcp.server import MCPServer + +mcp = MCPServer("Code Helper") + + +@mcp.prompt(title="Code review") +def review_code( + code: Annotated[str, Field(description="The code to review.")], + language: Annotated[str, Field(description="The language the code is written in.")] = "python", +) -> str: + """Review a piece of code.""" + return f"Please review this {language} code:\n\n{code}" diff --git a/docs_src/protocol_versions/__init__.py b/docs_src/protocol_versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/protocol_versions/tutorial001.py b/docs_src/protocol_versions/tutorial001.py new file mode 100644 index 0000000..23570e3 --- /dev/null +++ b/docs_src/protocol_versions/tutorial001.py @@ -0,0 +1,15 @@ +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +async def main() -> None: + async with Client(mcp) as client: + print(client.protocol_version) diff --git a/docs_src/protocol_versions/tutorial002.py b/docs_src/protocol_versions/tutorial002.py new file mode 100644 index 0000000..5c00c8b --- /dev/null +++ b/docs_src/protocol_versions/tutorial002.py @@ -0,0 +1,15 @@ +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +async def main() -> None: + async with Client(mcp, mode="legacy") as client: + print(client.protocol_version) diff --git a/docs_src/protocol_versions/tutorial003.py b/docs_src/protocol_versions/tutorial003.py new file mode 100644 index 0000000..5fd32ac --- /dev/null +++ b/docs_src/protocol_versions/tutorial003.py @@ -0,0 +1,15 @@ +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +async def main() -> None: + async with Client(mcp, mode="2026-07-28") as client: + print(client.protocol_version) diff --git a/docs_src/protocol_versions/tutorial004.py b/docs_src/protocol_versions/tutorial004.py new file mode 100644 index 0000000..c1b8fc6 --- /dev/null +++ b/docs_src/protocol_versions/tutorial004.py @@ -0,0 +1,19 @@ +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +async def main() -> None: + async with Client(mcp) as client: + saved = client.session.discover_result + + async with Client(mcp, mode="2026-07-28", prior_discover=saved) as client: + print(client.protocol_version) + print(client.server_info.name) diff --git a/docs_src/real_host/__init__.py b/docs_src/real_host/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/real_host/tutorial001.py b/docs_src/real_host/tutorial001.py new file mode 100644 index 0000000..1cd39c8 --- /dev/null +++ b/docs_src/real_host/tutorial001.py @@ -0,0 +1,34 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + +CATALOG = { + "Dune": "Frank Herbert", + "Neuromancer": "William Gibson", + "The Left Hand of Darkness": "Ursula K. Le Guin", +} + + +@mcp.tool() +def search_books(query: str) -> list[str]: + """Search the catalog by title or author.""" + needle = query.lower() + return [title for title, author in CATALOG.items() if needle in title.lower() or needle in author.lower()] + + +@mcp.tool() +def get_author(title: str) -> str: + """Look up the author of a book in the catalog.""" + if title not in CATALOG: + raise ValueError(f"No book titled {title!r} in the catalog.") + return CATALOG[title] + + +@mcp.resource("catalog://titles") +def titles() -> str: + """Every title in the catalog, one per line.""" + return "\n".join(sorted(CATALOG)) + + +if __name__ == "__main__": + mcp.run() diff --git a/docs_src/resources/__init__.py b/docs_src/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/resources/tutorial001.py b/docs_src/resources/tutorial001.py new file mode 100644 index 0000000..99c6d89 --- /dev/null +++ b/docs_src/resources/tutorial001.py @@ -0,0 +1,9 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.resource("config://app") +def get_config() -> str: + """The active shop configuration.""" + return "theme=dark\nlanguage=en" diff --git a/docs_src/resources/tutorial002.py b/docs_src/resources/tutorial002.py new file mode 100644 index 0000000..557fa92 --- /dev/null +++ b/docs_src/resources/tutorial002.py @@ -0,0 +1,15 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.resource("config://app") +def get_config() -> str: + """The active shop configuration.""" + return "theme=dark\nlanguage=en" + + +@mcp.resource("users://{user_id}/profile") +def get_user_profile(user_id: str) -> str: + """A customer's profile.""" + return f"User {user_id}: 12 orders since 2021." diff --git a/docs_src/resources/tutorial003.py b/docs_src/resources/tutorial003.py new file mode 100644 index 0000000..10881d3 --- /dev/null +++ b/docs_src/resources/tutorial003.py @@ -0,0 +1,23 @@ +import base64 + +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.resource("docs://readme", mime_type="text/markdown") +def readme() -> str: + """How to use this server.""" + return "# Bookshop\n\nSearch the catalog with the `search_books` tool." + + +@mcp.resource("stats://catalog", mime_type="application/json") +def catalog_stats() -> dict[str, int]: + """Live counts for the catalog.""" + return {"books": 1204, "authors": 391} + + +@mcp.resource("covers://placeholder", mime_type="image/gif") +def placeholder_cover() -> bytes: + """A 1x1 transparent GIF, shown when a book has no cover.""" + return base64.b64decode("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7") diff --git a/docs_src/run/__init__.py b/docs_src/run/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/run/tutorial001.py b/docs_src/run/tutorial001.py new file mode 100644 index 0000000..c8cd928 --- /dev/null +++ b/docs_src/run/tutorial001.py @@ -0,0 +1,13 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +if __name__ == "__main__": + mcp.run() diff --git a/docs_src/run/tutorial002.py b/docs_src/run/tutorial002.py new file mode 100644 index 0000000..686dc44 --- /dev/null +++ b/docs_src/run/tutorial002.py @@ -0,0 +1,13 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +if __name__ == "__main__": + mcp.run(transport="streamable-http", port=3001) diff --git a/docs_src/run/tutorial003.py b/docs_src/run/tutorial003.py new file mode 100644 index 0000000..19a9c11 --- /dev/null +++ b/docs_src/run/tutorial003.py @@ -0,0 +1,13 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop", log_level="DEBUG") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +if __name__ == "__main__": + mcp.run() diff --git a/docs_src/sampling_and_roots/__init__.py b/docs_src/sampling_and_roots/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/sampling_and_roots/tutorial001.py b/docs_src/sampling_and_roots/tutorial001.py new file mode 100644 index 0000000..c1e041c --- /dev/null +++ b/docs_src/sampling_and_roots/tutorial001.py @@ -0,0 +1,22 @@ +from typing import Annotated + +from mcp_types import CreateMessageResult, SamplingMessage, TextContent + +from mcp.server import MCPServer +from mcp.server.mcpserver import Resolve, Sample + +mcp = MCPServer("Bookshop") + + +def draft_blurb(title: str) -> Sample: + prompt = f"Write a one-sentence blurb for the book {title!r}." + return Sample( + [SamplingMessage(role="user", content=TextContent(type="text", text=prompt))], + max_tokens=60, + ) + + +@mcp.tool() +async def blurb(title: str, draft: Annotated[CreateMessageResult, Resolve(draft_blurb)]) -> str: + """Draft a blurb for a book.""" + return draft.content.text if draft.content.type == "text" else "No blurb." diff --git a/docs_src/sampling_and_roots/tutorial002.py b/docs_src/sampling_and_roots/tutorial002.py new file mode 100644 index 0000000..44a1d10 --- /dev/null +++ b/docs_src/sampling_and_roots/tutorial002.py @@ -0,0 +1,20 @@ +from typing import Annotated + +from mcp_types import ListRootsResult + +from mcp.server import MCPServer +from mcp.server.mcpserver import ListRoots, Resolve + +mcp = MCPServer("Bookshop") + + +def workspace_roots() -> ListRoots: + return ListRoots() + + +@mcp.tool() +async def catalog_folder(roots: Annotated[ListRootsResult, Resolve(workspace_roots)]) -> str: + """Pick the folder the catalog export should go to.""" + if not roots.roots: + return "No workspace folders shared." + return str(roots.roots[0].uri) diff --git a/docs_src/session_groups/__init__.py b/docs_src/session_groups/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/session_groups/tutorial001.py b/docs_src/session_groups/tutorial001.py new file mode 100644 index 0000000..08bec6d --- /dev/null +++ b/docs_src/session_groups/tutorial001.py @@ -0,0 +1,15 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Library") + + +@mcp.tool() +def search(query: str) -> str: + """Search the library catalog.""" + return f"3 books match {query!r}." + + +@mcp.resource("library://hours") +def hours() -> str: + """When the library is open.""" + return "Mon-Fri 09:00-17:00" diff --git a/docs_src/session_groups/tutorial002.py b/docs_src/session_groups/tutorial002.py new file mode 100644 index 0000000..154c279 --- /dev/null +++ b/docs_src/session_groups/tutorial002.py @@ -0,0 +1,9 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Web") + + +@mcp.tool() +def search(query: str) -> str: + """Search the web.""" + return f"12 pages match {query!r}." diff --git a/docs_src/session_groups/tutorial003.py b/docs_src/session_groups/tutorial003.py new file mode 100644 index 0000000..f4360f5 --- /dev/null +++ b/docs_src/session_groups/tutorial003.py @@ -0,0 +1,19 @@ +import asyncio + +from mcp import ClientSessionGroup, StdioServerParameters + + +async def main() -> None: + library = StdioServerParameters(command="uv", args=["run", "mcp", "run", "library_server.py"]) + web = StdioServerParameters(command="uv", args=["run", "mcp", "run", "web_server.py"]) + + async with ClientSessionGroup() as group: + await group.connect_to_server(library) + await group.connect_to_server(web) + + result = await group.call_tool("search", {"query": "model context protocol"}) + print(result.structured_content) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs_src/session_groups/tutorial004.py b/docs_src/session_groups/tutorial004.py new file mode 100644 index 0000000..7d10766 --- /dev/null +++ b/docs_src/session_groups/tutorial004.py @@ -0,0 +1,26 @@ +import asyncio + +from mcp_types import Implementation + +from mcp import ClientSessionGroup, StdioServerParameters + + +def by_server(name: str, server_info: Implementation) -> str: + return f"{server_info.name}.{name}" + + +async def main() -> None: + library = StdioServerParameters(command="uv", args=["run", "mcp", "run", "library_server.py"]) + web = StdioServerParameters(command="uv", args=["run", "mcp", "run", "web_server.py"]) + + async with ClientSessionGroup(component_name_hook=by_server) as group: + await group.connect_to_server(library) + await group.connect_to_server(web) + + print(sorted(group.tools)) + result = await group.call_tool("Web.search", {"query": "model context protocol"}) + print(result.structured_content) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs_src/structured_output/__init__.py b/docs_src/structured_output/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/structured_output/tutorial001.py b/docs_src/structured_output/tutorial001.py new file mode 100644 index 0000000..191c9bd --- /dev/null +++ b/docs_src/structured_output/tutorial001.py @@ -0,0 +1,11 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + +READINGS = {"London": 17, "Cairo": 34, "Reykjavik": 4} + + +@mcp.tool() +def get_temperature(city: str) -> int: + """Current temperature in a city, in whole degrees Celsius.""" + return READINGS[city] diff --git a/docs_src/structured_output/tutorial002.py b/docs_src/structured_output/tutorial002.py new file mode 100644 index 0000000..8ea0ef9 --- /dev/null +++ b/docs_src/structured_output/tutorial002.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel, Field + +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + + +class WeatherData(BaseModel): + temperature: float = Field(description="Degrees Celsius.") + humidity: float = Field(description="Relative humidity, 0 to 1.") + conditions: str + + +@mcp.tool() +def get_weather(city: str) -> WeatherData: + """Current weather for a city.""" + return WeatherData(temperature=16.2, humidity=0.83, conditions="Overcast") diff --git a/docs_src/structured_output/tutorial003.py b/docs_src/structured_output/tutorial003.py new file mode 100644 index 0000000..783dc40 --- /dev/null +++ b/docs_src/structured_output/tutorial003.py @@ -0,0 +1,17 @@ +from typing import TypedDict + +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + + +class WeatherData(TypedDict): + temperature: float + humidity: float + conditions: str + + +@mcp.tool() +def get_weather(city: str) -> WeatherData: + """Current weather for a city.""" + return WeatherData(temperature=16.2, humidity=0.83, conditions="Overcast") diff --git a/docs_src/structured_output/tutorial004.py b/docs_src/structured_output/tutorial004.py new file mode 100644 index 0000000..fb4c6e2 --- /dev/null +++ b/docs_src/structured_output/tutorial004.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass + +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + + +@dataclass +class WeatherData: + temperature: float + humidity: float + conditions: str + + +@mcp.tool() +def get_weather(city: str) -> WeatherData: + """Current weather for a city.""" + return WeatherData(temperature=16.2, humidity=0.83, conditions="Overcast") diff --git a/docs_src/structured_output/tutorial005.py b/docs_src/structured_output/tutorial005.py new file mode 100644 index 0000000..7bbaae5 --- /dev/null +++ b/docs_src/structured_output/tutorial005.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel + +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + + +class WeatherData(BaseModel): + temperature: float + humidity: float + conditions: str + + +@mcp.tool() +def get_forecast(city: str, days: int) -> list[WeatherData]: + """Daily forecast for a city.""" + return [WeatherData(temperature=16.2 + day, humidity=0.83, conditions="Overcast") for day in range(days)] diff --git a/docs_src/structured_output/tutorial006.py b/docs_src/structured_output/tutorial006.py new file mode 100644 index 0000000..205abd2 --- /dev/null +++ b/docs_src/structured_output/tutorial006.py @@ -0,0 +1,11 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + +READINGS = {"London": 16.2, "Cairo": 34.1, "Reykjavik": 4.4} + + +@mcp.tool() +def get_temperatures(cities: list[str]) -> dict[str, float]: + """Current temperature for each city, in degrees Celsius.""" + return {city: READINGS[city] for city in cities} diff --git a/docs_src/structured_output/tutorial007.py b/docs_src/structured_output/tutorial007.py new file mode 100644 index 0000000..c889a30 --- /dev/null +++ b/docs_src/structured_output/tutorial007.py @@ -0,0 +1,21 @@ +import json + +from pydantic import BaseModel + +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + +UPSTREAM = {"London": '{"temperature": 16.2, "conditions": "Overcast"}'} + + +class WeatherData(BaseModel): + temperature: float + humidity: float + conditions: str + + +@mcp.tool() +def get_weather(city: str) -> WeatherData: + """Current weather for a city.""" + return json.loads(UPSTREAM[city]) diff --git a/docs_src/structured_output/tutorial008.py b/docs_src/structured_output/tutorial008.py new file mode 100644 index 0000000..b0e9923 --- /dev/null +++ b/docs_src/structured_output/tutorial008.py @@ -0,0 +1,9 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + + +@mcp.tool(structured_output=False) +def weather_report(city: str) -> str: + """A human-readable weather report for a city.""" + return f"{city}: 17 degrees, overcast, light rain easing by evening." diff --git a/docs_src/structured_output/tutorial009.py b/docs_src/structured_output/tutorial009.py new file mode 100644 index 0000000..c5a2d17 --- /dev/null +++ b/docs_src/structured_output/tutorial009.py @@ -0,0 +1,15 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + + +class Station: + def __init__(self, name: str, online: bool): + self.name = name + self.online = online + + +@mcp.tool() +def get_station(name: str) -> Station: + """Look up a weather station by name.""" + return Station(name=name, online=True) diff --git a/docs_src/subscriptions/__init__.py b/docs_src/subscriptions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/subscriptions/tutorial001.py b/docs_src/subscriptions/tutorial001.py new file mode 100644 index 0000000..45c858f --- /dev/null +++ b/docs_src/subscriptions/tutorial001.py @@ -0,0 +1,33 @@ +from mcp.server.mcpserver import Context, MCPServer + +mcp = MCPServer("Sprint Board") + +BOARDS = { + "sprint": {"design": False, "build": False, "ship": False}, + "backlog": {"tidy docs": False}, +} + + +@mcp.resource("board://{name}") +def board(name: str) -> str: + tasks = BOARDS[name] + return "\n".join(f"[{'x' if done else ' '}] {task}" for task, done in tasks.items()) + + +@mcp.tool() +async def complete_task(board: str, task: str, ctx: Context) -> str: + BOARDS[board][task] = True + await ctx.notify_resource_updated(f"board://{board}") + return f"{task}: done" + + +def sprint_report() -> str: + done = sum(done for tasks in BOARDS.values() for done in tasks.values()) + return f"{done} task(s) done" + + +@mcp.tool() +async def enable_reports(ctx: Context) -> str: + mcp.add_tool(sprint_report) + await ctx.notify_tools_changed() + return "reporting is live" diff --git a/docs_src/subscriptions/tutorial002.py b/docs_src/subscriptions/tutorial002.py new file mode 100644 index 0000000..39e42dc --- /dev/null +++ b/docs_src/subscriptions/tutorial002.py @@ -0,0 +1,49 @@ +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, ResourceUpdated + +bus = InMemorySubscriptionBus() +listen_handler = ListenHandler(bus) + +BOARD = {"design": False, "build": False} + +COMPLETE_TASK_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"task": {"type": "string"}}, + "required": ["task"], +} + + +async def read_resource( + ctx: ServerRequestContext[Any], params: types.ReadResourceRequestParams +) -> types.ReadResourceResult: + board = "\n".join(f"[{'x' if done else ' '}] {task}" for task, done in BOARD.items()) + return types.ReadResourceResult(contents=[types.TextResourceContents(uri=params.uri, text=board)]) + + +async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[types.Tool(name="complete_task", description="Mark a task done.", input_schema=COMPLETE_TASK_SCHEMA)] + ) + + +async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + args = params.arguments or {} + BOARD[args["task"]] = True + await bus.publish(ResourceUpdated(uri="board://sprint")) + return types.CallToolResult(content=[types.TextContent(type="text", text="done")]) + + +server = Server( + "sprint-board", + on_read_resource=read_resource, + on_list_tools=list_tools, + on_call_tool=call_tool, + on_subscriptions_listen=listen_handler, +) diff --git a/docs_src/subscriptions/tutorial003.py b/docs_src/subscriptions/tutorial003.py new file mode 100644 index 0000000..811f694 --- /dev/null +++ b/docs_src/subscriptions/tutorial003.py @@ -0,0 +1,30 @@ +from mcp_types import TextResourceContents + +from mcp import Client +from mcp.client.subscriptions import ResourceUpdated, ToolsListChanged + +BOARD = "board://sprint" + + +async def read_board(client: Client, uri: str = BOARD) -> str: + [contents] = (await client.read_resource(uri)).contents + assert isinstance(contents, TextResourceContents) + return contents.text + + +async def follow_board(client: Client) -> None: + async with client.listen(tools_list_changed=True, resource_subscriptions=[BOARD]) as sub: + async for event in sub: + match event: + case ResourceUpdated(uri=uri): + print(await read_board(client, uri)) + case ToolsListChanged(): + tools = await client.list_tools() + print("tools:", [tool.name for tool in tools.tools]) + case _: + pass # kinds the filter did not ask for never arrive + + +async def main() -> None: + async with Client("http://localhost:8000/mcp") as client: + await follow_board(client) diff --git a/docs_src/subscriptions/tutorial004_anyio.py b/docs_src/subscriptions/tutorial004_anyio.py new file mode 100644 index 0000000..1ca4995 --- /dev/null +++ b/docs_src/subscriptions/tutorial004_anyio.py @@ -0,0 +1,32 @@ +import anyio + +from mcp import Client +from mcp.client.subscriptions import Subscription + +from .tutorial003 import BOARD, read_board + + +async def watch(client: Client, sub: Subscription) -> None: + async for _event in sub: + board = await read_board(client) + print(board) + if "[ ]" not in board: + return # sprint finished: the stream closes when run_sprint leaves the block + + +async def run_sprint(client: Client) -> None: + async with client.listen(resource_subscriptions=[BOARD]) as sub: + print(await read_board(client)) # snapshot: acknowledged, so nothing after this is missed + async with anyio.create_task_group() as tg: + tg.start_soon(watch, client, sub) + for task in ("design", "build", "ship"): + await client.call_tool("complete_task", {"board": "sprint", "task": task}) + + +async def main() -> None: + async with Client("http://localhost:8000/mcp") as client: + await run_sprint(client) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/subscriptions/tutorial004_asyncio.py b/docs_src/subscriptions/tutorial004_asyncio.py new file mode 100644 index 0000000..2e4c685 --- /dev/null +++ b/docs_src/subscriptions/tutorial004_asyncio.py @@ -0,0 +1,32 @@ +import asyncio + +from mcp import Client +from mcp.client.subscriptions import Subscription + +from .tutorial003 import BOARD, read_board + + +async def watch(client: Client, sub: Subscription) -> None: + async for _event in sub: + board = await read_board(client) + print(board) + if "[ ]" not in board: + return # sprint finished: the stream closes when run_sprint leaves the block + + +async def run_sprint(client: Client) -> None: + async with client.listen(resource_subscriptions=[BOARD]) as sub: + print(await read_board(client)) # snapshot: acknowledged, so nothing after this is missed + watcher = asyncio.create_task(watch(client, sub)) + for task in ("design", "build", "ship"): + await client.call_tool("complete_task", {"board": "sprint", "task": task}) + await watcher # returns once the watcher has seen the finished board + + +async def main() -> None: + async with Client("http://localhost:8000/mcp") as client: + await run_sprint(client) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs_src/subscriptions/tutorial004_trio.py b/docs_src/subscriptions/tutorial004_trio.py new file mode 100644 index 0000000..da40715 --- /dev/null +++ b/docs_src/subscriptions/tutorial004_trio.py @@ -0,0 +1,32 @@ +import trio + +from mcp import Client +from mcp.client.subscriptions import Subscription + +from .tutorial003 import BOARD, read_board + + +async def watch(client: Client, sub: Subscription) -> None: + async for _event in sub: + board = await read_board(client) + print(board) + if "[ ]" not in board: + return # sprint finished: the stream closes when run_sprint leaves the block + + +async def run_sprint(client: Client) -> None: + async with client.listen(resource_subscriptions=[BOARD]) as sub: + print(await read_board(client)) # snapshot: acknowledged, so nothing after this is missed + async with trio.open_nursery() as nursery: + nursery.start_soon(watch, client, sub) + for task in ("design", "build", "ship"): + await client.call_tool("complete_task", {"board": "sprint", "task": task}) + + +async def main() -> None: + async with Client("http://localhost:8000/mcp") as client: + await run_sprint(client) + + +if __name__ == "__main__": + trio.run(main) diff --git a/docs_src/subscriptions/tutorial005.py b/docs_src/subscriptions/tutorial005.py new file mode 100644 index 0000000..17387fa --- /dev/null +++ b/docs_src/subscriptions/tutorial005.py @@ -0,0 +1,20 @@ +import anyio + +from mcp import Client +from mcp.client.subscriptions import SubscriptionLost + +from .tutorial003 import read_board + + +async def keep_following(client: Client) -> None: + while True: + try: + async with client.listen(resource_subscriptions=["board://sprint"]) as sub: + print(await read_board(client)) # refetch: no replay across streams + async for _event in sub: + print(await read_board(client)) + except SubscriptionLost: + pass + # Either ending means the stream is gone. Back off before re-listening: + # a graceful close may be the server shedding load. + await anyio.sleep(1) diff --git a/docs_src/testing/__init__.py b/docs_src/testing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/testing/tutorial001.py b/docs_src/testing/tutorial001.py new file mode 100644 index 0000000..ab7938b --- /dev/null +++ b/docs_src/testing/tutorial001.py @@ -0,0 +1,9 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Calculator") + + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b diff --git a/docs_src/tools/__init__.py b/docs_src/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/tools/tutorial001.py b/docs_src/tools/tutorial001.py new file mode 100644 index 0000000..b324ec8 --- /dev/null +++ b/docs_src/tools/tutorial001.py @@ -0,0 +1,9 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str, limit: int) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r} (showing up to {limit})." diff --git a/docs_src/tools/tutorial002.py b/docs_src/tools/tutorial002.py new file mode 100644 index 0000000..1ade813 --- /dev/null +++ b/docs_src/tools/tutorial002.py @@ -0,0 +1,9 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str, limit: int = 10) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r} (showing up to {limit})." diff --git a/docs_src/tools/tutorial003.py b/docs_src/tools/tutorial003.py new file mode 100644 index 0000000..1bc415a --- /dev/null +++ b/docs_src/tools/tutorial003.py @@ -0,0 +1,18 @@ +from typing import Annotated, Literal + +from pydantic import Field + +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books( + query: Annotated[str, Field(description="Title or author to search for.")], + limit: Annotated[int, Field(ge=1, le=50, description="Maximum number of results.")] = 10, + genre: Literal["fiction", "non-fiction", "poetry"] | None = None, +) -> str: + """Search the catalog by title or author.""" + where = f" in {genre}" if genre else "" + return f"Found 3 books matching {query!r}{where} (showing up to {limit})." diff --git a/docs_src/tools/tutorial004.py b/docs_src/tools/tutorial004.py new file mode 100644 index 0000000..a52f066 --- /dev/null +++ b/docs_src/tools/tutorial004.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel, Field + +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +class Book(BaseModel): + title: str + author: str + year: int = Field(ge=1450, description="Year of first publication.") + + +@mcp.tool() +def add_book(book: Book) -> str: + """Add a book to the catalog.""" + return f"Added {book.title!r} by {book.author} ({book.year})." diff --git a/docs_src/tools/tutorial005.py b/docs_src/tools/tutorial005.py new file mode 100644 index 0000000..f9fcbce --- /dev/null +++ b/docs_src/tools/tutorial005.py @@ -0,0 +1,14 @@ +from mcp_types import ToolAnnotations + +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool( + title="Search the catalog", + annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False), +) +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." diff --git a/docs_src/troubleshooting/__init__.py b/docs_src/troubleshooting/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/troubleshooting/tutorial001.py b/docs_src/troubleshooting/tutorial001.py new file mode 100644 index 0000000..e83a552 --- /dev/null +++ b/docs_src/troubleshooting/tutorial001.py @@ -0,0 +1,22 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver.exceptions import ResourceNotFoundError + +mcp = MCPServer("Weather") + +FORECASTS = {"London": "Rain.", "Cairo": "Sun."} + + +@mcp.tool() +def forecast(city: str) -> str: + """Today's forecast for one city.""" + if city not in FORECASTS: + raise ValueError(f"No forecast for {city!r}.") + return FORECASTS[city] + + +@mcp.resource("weather://{city}") +def report(city: str) -> str: + """The full report for one city.""" + if city not in FORECASTS: + raise ResourceNotFoundError(f"No forecast for {city!r}.") + return f"{city}: {FORECASTS[city]}" diff --git a/docs_src/troubleshooting/tutorial002.py b/docs_src/troubleshooting/tutorial002.py new file mode 100644 index 0000000..ec68725 --- /dev/null +++ b/docs_src/troubleshooting/tutorial002.py @@ -0,0 +1,15 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + + +@mcp.tool(name="forecast") +def forecast_today(city: str) -> str: + """Today's forecast for one city.""" + return f"{city}: Rain." + + +@mcp.tool(name="forecast") # Same name. This registration is dropped. +def forecast_hourly(city: str, hours: int) -> str: + """The next few hours for one city.""" + return f"{city}: Rain for {hours}h." diff --git a/docs_src/troubleshooting/tutorial003.py b/docs_src/troubleshooting/tutorial003.py new file mode 100644 index 0000000..e2e07f6 --- /dev/null +++ b/docs_src/troubleshooting/tutorial003.py @@ -0,0 +1,12 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + + +@mcp.tool() +def forecast(city: str) -> str: + """Today's forecast for one city.""" + return f"{city}: Rain." + + +app = mcp.streamable_http_app() diff --git a/docs_src/troubleshooting/tutorial004.py b/docs_src/troubleshooting/tutorial004.py new file mode 100644 index 0000000..b78fa1d --- /dev/null +++ b/docs_src/troubleshooting/tutorial004.py @@ -0,0 +1,18 @@ +from mcp.server import MCPServer +from mcp.server.transport_security import TransportSecuritySettings + +mcp = MCPServer("Weather") + + +@mcp.tool() +def forecast(city: str) -> str: + """Today's forecast for one city.""" + return f"{city}: Rain." + + +app = mcp.streamable_http_app( + transport_security=TransportSecuritySettings( + allowed_hosts=["mcp.example.com", "mcp.example.com:*"], + allowed_origins=["https://app.example.com"], + ) +) diff --git a/docs_src/troubleshooting/tutorial005.py b/docs_src/troubleshooting/tutorial005.py new file mode 100644 index 0000000..ca990da --- /dev/null +++ b/docs_src/troubleshooting/tutorial005.py @@ -0,0 +1,16 @@ +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + + +@mcp.tool() +def forecast(city: str) -> str: + """Today's forecast for one city.""" + return f"{city}: Rain." + + +# The mount works. The MCP app's own lifespan never runs. +app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())]) diff --git a/docs_src/troubleshooting/tutorial006.py b/docs_src/troubleshooting/tutorial006.py new file mode 100644 index 0000000..2c995f5 --- /dev/null +++ b/docs_src/troubleshooting/tutorial006.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel + +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Bistro") + + +class Confirmation(BaseModel): + confirm: bool + + +@mcp.tool() +async def book_table(date: str, ctx: Context) -> str: + """Book a table at the bistro.""" + result = await ctx.elicit(f"Book a table for {date}?", schema=Confirmation) + if result.action == "accept" and result.data.confirm: + return f"Booked for {date}." + return "No booking made." diff --git a/docs_src/troubleshooting/tutorial007.py b/docs_src/troubleshooting/tutorial007.py new file mode 100644 index 0000000..051c29c --- /dev/null +++ b/docs_src/troubleshooting/tutorial007.py @@ -0,0 +1,25 @@ +from typing import Annotated + +from pydantic import BaseModel + +from mcp.server import MCPServer +from mcp.server.mcpserver import Elicit, Resolve + +mcp = MCPServer("Bistro") + + +class Confirmation(BaseModel): + confirm: bool + + +async def ask_to_confirm(date: str) -> Elicit[Confirmation]: + """Resolver: ask the user to confirm the booking.""" + return Elicit(f"Book a table for {date}?", Confirmation) + + +@mcp.tool() +async def book_table(date: str, answer: Annotated[Confirmation, Resolve(ask_to_confirm)]) -> str: + """Book a table at the bistro.""" + if answer.confirm: + return f"Booked for {date}." + return "No booking made." diff --git a/docs_src/troubleshooting/tutorial008.py b/docs_src/troubleshooting/tutorial008.py new file mode 100644 index 0000000..1779cff --- /dev/null +++ b/docs_src/troubleshooting/tutorial008.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel + +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Bistro") + + +class Confirmation(BaseModel): + confirm: bool + + +@mcp.tool() +async def book_table(date: str, ctx: Context) -> str: + """Book a table at the bistro.""" + result = await ctx.elicit(f"Book a table for {date}?", schema=Confirmation) + if result.action == "accept" and result.data.confirm: + return f"Booked for {date}." + return "No booking made." + + +# Stateless HTTP: every request is its own world. No channel back to the client. +app = mcp.streamable_http_app(stateless_http=True) diff --git a/docs_src/uri_templates/__init__.py b/docs_src/uri_templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/uri_templates/tutorial001.py b/docs_src/uri_templates/tutorial001.py new file mode 100644 index 0000000..87685c0 --- /dev/null +++ b/docs_src/uri_templates/tutorial001.py @@ -0,0 +1,43 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + +BOOKS = { + "978-0441172719": {"title": "Dune", "author": "Frank Herbert"}, + "978-0553293357": {"title": "Foundation", "author": "Isaac Asimov"}, +} + +MANUALS = { + "printing/setup.md": "# Printer setup\n\nLoad paper, then power on.", + "returns.md": "# Returns policy\n\nThirty days with a receipt.", +} + + +@mcp.resource("books://{isbn}") +def get_book(isbn: str) -> dict[str, str]: + """A single book by ISBN.""" + return BOOKS[isbn] + + +@mcp.resource("orders://{order_id}") +def get_order(order_id: int) -> dict[str, object]: + """An order by its numeric id.""" + return {"order_id": order_id, "next_order": order_id + 1, "status": "shipped"} + + +@mcp.resource("manuals://{+path}") +def read_manual(path: str) -> str: + """A staff manual page. The path keeps its slashes.""" + return MANUALS[path] + + +@mcp.resource("reviews://{isbn}{?limit,sort}") +def list_reviews(isbn: str, limit: int = 10, sort: str = "newest") -> str: + """Reviews of a book, optionally limited and sorted.""" + return f"{limit} {sort} reviews of {BOOKS[isbn]['title']}" + + +@mcp.resource("shelves://browse{/path*}") +def browse_shelf(path: list[str]) -> str: + """A shelf in the category tree, addressed by segments.""" + return " > ".join(["catalog", *path]) diff --git a/docs_src/uri_templates/tutorial002.py b/docs_src/uri_templates/tutorial002.py new file mode 100644 index 0000000..2ad1ec7 --- /dev/null +++ b/docs_src/uri_templates/tutorial002.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from mcp.server import MCPServer +from mcp.shared.path_security import safe_join + +mcp = MCPServer("Bookshop") + +DOCS_ROOT = Path("./manuals") + + +@mcp.resource("manuals://{+path}") +def read_manual(path: str) -> str: + """A staff manual page, served from a directory on disk.""" + return safe_join(DOCS_ROOT, path).read_text() diff --git a/docs_src/uri_templates/tutorial003.py b/docs_src/uri_templates/tutorial003.py new file mode 100644 index 0000000..bc7a59c --- /dev/null +++ b/docs_src/uri_templates/tutorial003.py @@ -0,0 +1,25 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver import ResourceSecurity + +mcp = MCPServer("Bookshop") + + +@mcp.resource( + "imports://preview/{+source}", + security=ResourceSecurity(exempt_params={"source"}), +) +def preview_import(source: str) -> str: + """Preview a catalog import. `source` may be an absolute path.""" + return f"Would import from {source}" + + +relaxed = MCPServer( + "Bookshop", + resource_security=ResourceSecurity(reject_path_traversal=False), +) + + +@relaxed.resource("imports://preview/{+source}") +def preview_import_relaxed(source: str) -> str: + """The server-wide flag exempts every resource on `relaxed`.""" + return f"Would import from {source}" diff --git a/docs_src/uri_templates/tutorial004.py b/docs_src/uri_templates/tutorial004.py new file mode 100644 index 0000000..c1920b3 --- /dev/null +++ b/docs_src/uri_templates/tutorial004.py @@ -0,0 +1,28 @@ +from mcp_types import ( + ListResourcesResult, + PaginatedRequestParams, + ReadResourceRequestParams, + ReadResourceResult, + Resource, + TextResourceContents, +) + +from mcp.server import Server, ServerRequestContext + +RESOURCES = { + "config://shop": '{"currency": "USD", "tax_rate": 0.08}', + "status://health": "ok", +} + + +async def list_resources(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListResourcesResult: + return ListResourcesResult(resources=[Resource(name=uri, uri=uri) for uri in RESOURCES]) + + +async def read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult: + if (text := RESOURCES.get(params.uri)) is not None: + return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=text)]) + raise ValueError(f"Unknown resource: {params.uri}") + + +server = Server("Bookshop", on_list_resources=list_resources, on_read_resource=read_resource) diff --git a/docs_src/uri_templates/tutorial005.py b/docs_src/uri_templates/tutorial005.py new file mode 100644 index 0000000..716ff08 --- /dev/null +++ b/docs_src/uri_templates/tutorial005.py @@ -0,0 +1,55 @@ +from mcp_types import ( + ListResourceTemplatesResult, + PaginatedRequestParams, + ReadResourceRequestParams, + ReadResourceResult, + ResourceTemplate, + TextResourceContents, +) + +from mcp.server import Server, ServerRequestContext +from mcp.shared.path_security import contains_path_traversal, is_absolute_path +from mcp.shared.uri_template import UriTemplate + +TEMPLATES = { + "manuals": UriTemplate.parse("manuals://{+path}"), + "books": UriTemplate.parse("books://{isbn}"), +} + +MANUALS = {"printing/setup.md": "# Printer setup", "returns.md": "# Returns policy"} +BOOKS = {"978-0441172719": "Dune by Frank Herbert"} + + +def read_manual_safely(path: str) -> str: + if contains_path_traversal(path) or is_absolute_path(path): + raise ValueError("rejected") + return MANUALS[path] + + +async def read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult: + if (matched := TEMPLATES["manuals"].match(params.uri)) is not None: + text = read_manual_safely(str(matched["path"])) + return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=text)]) + + if (matched := TEMPLATES["books"].match(params.uri)) is not None: + text = BOOKS[str(matched["isbn"])] + return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=text)]) + + raise ValueError(f"Unknown resource: {params.uri}") + + +async def list_resource_templates( + ctx: ServerRequestContext, params: PaginatedRequestParams | None +) -> ListResourceTemplatesResult: + return ListResourceTemplatesResult( + resource_templates=[ + ResourceTemplate(name=name, uri_template=str(template)) for name, template in TEMPLATES.items() + ] + ) + + +server = Server( + "Bookshop", + on_read_resource=read_resource, + on_list_resource_templates=list_resource_templates, +) diff --git a/docs_src/whats_new/__init__.py b/docs_src/whats_new/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/whats_new/tutorial001.py b/docs_src/whats_new/tutorial001.py new file mode 100644 index 0000000..5e41ae1 --- /dev/null +++ b/docs_src/whats_new/tutorial001.py @@ -0,0 +1,37 @@ +from mcp_types import ( + INVALID_PARAMS, + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) + +from mcp import MCPError +from mcp.server import Server, ServerRequestContext + +SEARCH_BOOKS = Tool( + name="search_books", + description="Search the catalog by title or author.", + input_schema={ # (1)! + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, +) + + +async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: # (2)! + return ListToolsResult(tools=[SEARCH_BOOKS]) # (3)! + + +async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: # (4)! + if params.name != "search_books": + raise MCPError(INVALID_PARAMS, f"Unknown tool: {params.name}") # (5)! + args = params.arguments or {} # (6)! + text = f"Found 3 books matching {args['query']!r}." + return CallToolResult(content=[TextContent(type="text", text=text)]) # (7)! + + +server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool) # (8)! diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..68c8ac9 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,22 @@ +# Python SDK examples + +- [`stories/`](stories/) — **the canonical reference.** One self-verifying + example per protocol feature, each with its own README. Start with + [`stories/tools/`](stories/tools/); the [stories README](stories/README.md) + has the full table and how to run them. +- [`snippets/`](snippets/) — short extracts that were embedded into the v1 + README (now on the `v1.x` branch); superseded by `docs_src/`, which the docs + and README embed today. Retained pending consolidation into `stories/`. +- [`servers/everything-server/`](servers/everything-server/) — the conformance + target for the cross-SDK + [conformance suite](https://github.com/modelcontextprotocol/conformance). + Exercises every server capability in one process. +- [`mcpserver/`](mcpserver/) — single-file v1-era examples retained for the + migration guide; superseded by `stories/` and slated for removal. +- [`clients/`](clients/) and the remaining [`servers/`](servers/) directories + (`simple-*`, `sse-polling-demo`, `structured-output-lowlevel`) — standalone + v1-era projects retained pending consolidation into `stories/` (the + `simple-auth` pair is still linked from `docs/run/authorization.md` and `docs/client/oauth-clients.md`). + +For real-world servers see the +[servers repository](https://github.com/modelcontextprotocol/servers). diff --git a/examples/clients/simple-auth-client/README.md b/examples/clients/simple-auth-client/README.md new file mode 100644 index 0000000..708c037 --- /dev/null +++ b/examples/clients/simple-auth-client/README.md @@ -0,0 +1,98 @@ +# Simple Auth Client Example + +A demonstration of how to use the MCP Python SDK with OAuth authentication over streamable HTTP or SSE transport. + +## Features + +- OAuth 2.0 authentication with PKCE +- Support for both StreamableHTTP and SSE transports +- Interactive command-line interface + +## Installation + +```bash +cd examples/clients/simple-auth-client +uv sync --reinstall +``` + +## Usage + +### 1. Start an MCP server with OAuth support + +The simple-auth server example provides three server configurations. See [examples/servers/simple-auth/README.md](../../servers/simple-auth/README.md) for full details. + +#### Option A: New Architecture (Recommended) + +Separate Authorization Server and Resource Server: + +```bash +# Terminal 1: Start Authorization Server on port 9000 +cd examples/servers/simple-auth +uv run mcp-simple-auth-as --port=9000 + +# Terminal 2: Start Resource Server on port 8001 +cd examples/servers/simple-auth +uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http +``` + +#### Option B: Legacy Server (Backwards Compatibility) + +```bash +# Single server that acts as both AS and RS (port 8000) +cd examples/servers/simple-auth +uv run mcp-simple-auth-legacy --port=8000 --transport=streamable-http +``` + +### 2. Run the client + +```bash +# Connect to Resource Server (new architecture, default port 8001) +MCP_SERVER_PORT=8001 uv run mcp-simple-auth-client + +# Connect to Legacy Server (port 8000) +uv run mcp-simple-auth-client + +# Use SSE transport +MCP_SERVER_PORT=8001 MCP_TRANSPORT_TYPE=sse uv run mcp-simple-auth-client +``` + +### 3. Complete OAuth flow + +The client will open your browser for authentication. After completing OAuth, you can use commands: + +- `list` - List available tools +- `call [args]` - Call a tool with optional JSON arguments +- `quit` - Exit + +## Example + +```markdown +🚀 Simple MCP Auth Client +Connecting to: http://localhost:8001/mcp +Transport type: streamable-http + +🔗 Attempting to connect to http://localhost:8001/mcp... +📡 Opening StreamableHTTP transport connection with auth... +Opening browser for authorization: http://localhost:9000/authorize?... + +✅ Connected to MCP server at http://localhost:8001/mcp + +mcp> list +📋 Available tools: +1. get_time + Description: Get the current server time. + +mcp> call get_time +🔧 Tool 'get_time' result: +{"current_time": "2024-01-15T10:30:00", "timezone": "UTC", ...} + +mcp> quit +``` + +## Configuration + +| Environment Variable | Description | Default | +|---------------------|-------------|---------| +| `MCP_SERVER_PORT` | Port number of the MCP server | `8000` | +| `MCP_TRANSPORT_TYPE` | Transport type: `streamable-http` or `sse` | `streamable-http` | +| `MCP_CLIENT_METADATA_URL` | Optional URL for client metadata (CIMD) | None | diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/__init__.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/__init__.py new file mode 100644 index 0000000..06eb1f2 --- /dev/null +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/__init__.py @@ -0,0 +1 @@ +"""Simple OAuth client for MCP simple-auth server.""" diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py new file mode 100644 index 0000000..0d461d5 --- /dev/null +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py @@ -0,0 +1,390 @@ +#!/usr/bin/env python3 +"""Simple MCP client example with OAuth authentication support. + +This client connects to an MCP server using streamable HTTP transport with OAuth. + +""" + +from __future__ import annotations as _annotations + +import asyncio +import os +import socketserver +import threading +import time +import webbrowser +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any +from urllib.parse import parse_qs, urlparse + +import httpx +from mcp.client._transport import ReadStream, WriteStream +from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider, TokenStorage +from mcp.client.session import ClientSession +from mcp.client.sse import sse_client +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken +from mcp.shared.message import SessionMessage + + +class InMemoryTokenStorage(TokenStorage): + """Simple in-memory token storage implementation.""" + + def __init__(self): + self._tokens: OAuthToken | None = None + self._client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self._tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + self._tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self._client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self._client_info = client_info + + +class CallbackHandler(BaseHTTPRequestHandler): + """Simple HTTP handler to capture OAuth callback.""" + + def __init__( + self, + request: Any, + client_address: tuple[str, int], + server: socketserver.BaseServer, + callback_data: dict[str, Any], + ): + """Initialize with callback data storage.""" + self.callback_data = callback_data + super().__init__(request, client_address, server) + + def do_GET(self): + """Handle GET request from OAuth redirect.""" + parsed = urlparse(self.path) + query_params = parse_qs(parsed.query) + + if "code" in query_params: + self.callback_data["authorization_code"] = query_params["code"][0] + self.callback_data["state"] = query_params.get("state", [None])[0] + self.callback_data["iss"] = query_params.get("iss", [None])[0] + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write(b""" + + +

Authorization Successful!

+

You can close this window and return to the terminal.

+ + + + """) + elif "error" in query_params: + self.callback_data["error"] = query_params["error"][0] + self.send_response(400) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write( + f""" + + +

Authorization Failed

+

Error: {query_params["error"][0]}

+

You can close this window and return to the terminal.

+ + + """.encode() + ) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format: str, *args: Any): + """Suppress default logging.""" + + +class CallbackServer: + """Simple server to handle OAuth callbacks.""" + + def __init__(self, port: int = 3000): + self.port = port + self.server = None + self.thread = None + self.callback_data = {"authorization_code": None, "state": None, "iss": None, "error": None} + + def _create_handler_with_data(self): + """Create a handler class with access to callback data.""" + callback_data = self.callback_data + + class DataCallbackHandler(CallbackHandler): + def __init__( + self, + request: BaseHTTPRequestHandler, + client_address: tuple[str, int], + server: socketserver.BaseServer, + ): + super().__init__(request, client_address, server, callback_data) + + return DataCallbackHandler + + def start(self): + """Start the callback server in a background thread.""" + handler_class = self._create_handler_with_data() + self.server = HTTPServer(("localhost", self.port), handler_class) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + print(f"🖥️ Started callback server on http://localhost:{self.port}") + + def stop(self): + """Stop the callback server.""" + if self.server: + self.server.shutdown() + self.server.server_close() + if self.thread: + self.thread.join(timeout=1) + + def wait_for_callback(self, timeout: int = 300): + """Wait for OAuth callback with timeout.""" + start_time = time.time() + while time.time() - start_time < timeout: + if self.callback_data["authorization_code"]: + return self.callback_data["authorization_code"] + elif self.callback_data["error"]: + raise Exception(f"OAuth error: {self.callback_data['error']}") + time.sleep(0.1) + raise Exception("Timeout waiting for OAuth callback") + + @property + def state(self): + """The received state parameter.""" + return self.callback_data["state"] + + @property + def iss(self): + """The received iss parameter.""" + return self.callback_data["iss"] + + +class SimpleAuthClient: + """Simple MCP client with auth support.""" + + def __init__( + self, + server_url: str, + transport_type: str = "streamable-http", + client_metadata_url: str | None = None, + ): + self.server_url = server_url + self.transport_type = transport_type + self.client_metadata_url = client_metadata_url + self.session: ClientSession | None = None + + async def connect(self): + """Connect to the MCP server.""" + print(f"🔗 Attempting to connect to {self.server_url}...") + + try: + callback_server = CallbackServer(port=3030) + callback_server.start() + + async def callback_handler() -> AuthorizationCodeResult: + """Wait for OAuth callback and return auth code, state, and iss.""" + print("⏳ Waiting for authorization callback...") + try: + auth_code = callback_server.wait_for_callback(timeout=300) + return AuthorizationCodeResult(code=auth_code, state=callback_server.state, iss=callback_server.iss) + finally: + callback_server.stop() + + client_metadata_dict = { + "client_name": "Simple Auth Client", + "redirect_uris": ["http://localhost:3030/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + } + + async def _default_redirect_handler(authorization_url: str) -> None: + """Default redirect handler that opens the URL in a browser.""" + print(f"Opening browser for authorization: {authorization_url}") + webbrowser.open(authorization_url) + + # Create OAuth authentication handler using the new interface + # Use client_metadata_url to enable CIMD when the server supports it + oauth_auth = OAuthClientProvider( + server_url=self.server_url.replace("/mcp", ""), + client_metadata=OAuthClientMetadata.model_validate(client_metadata_dict), + storage=InMemoryTokenStorage(), + redirect_handler=_default_redirect_handler, + callback_handler=callback_handler, + client_metadata_url=self.client_metadata_url, + ) + + # Create transport with auth handler based on transport type + if self.transport_type == "sse": + print("📡 Opening SSE transport connection with auth...") + async with sse_client( + url=self.server_url, + auth=oauth_auth, + timeout=60.0, + ) as (read_stream, write_stream): + await self._run_session(read_stream, write_stream) + else: + print("📡 Opening StreamableHTTP transport connection with auth...") + async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: + async with streamable_http_client(url=self.server_url, http_client=custom_client) as ( + read_stream, + write_stream, + ): + await self._run_session(read_stream, write_stream) + + except Exception as e: + print(f"❌ Failed to connect: {e}") + import traceback + + traceback.print_exc() + + async def _run_session( + self, + read_stream: ReadStream[SessionMessage | Exception], + write_stream: WriteStream[SessionMessage], + ): + """Run the MCP session with the given streams.""" + print("🤝 Initializing MCP session...") + async with ClientSession(read_stream, write_stream) as session: + self.session = session + print("⚡ Starting session initialization...") + await session.initialize() + print("✨ Session initialization complete!") + + print(f"\n✅ Connected to MCP server at {self.server_url}") + + # Run interactive loop + await self.interactive_loop() + + async def list_tools(self): + """List available tools from the server.""" + if not self.session: + print("❌ Not connected to server") + return + + try: + result = await self.session.list_tools() + if hasattr(result, "tools") and result.tools: + print("\n📋 Available tools:") + for i, tool in enumerate(result.tools, 1): + print(f"{i}. {tool.name}") + if tool.description: + print(f" Description: {tool.description}") + print() + else: + print("No tools available") + except Exception as e: + print(f"❌ Failed to list tools: {e}") + + async def call_tool(self, tool_name: str, arguments: dict[str, Any] | None = None): + """Call a specific tool.""" + if not self.session: + print("❌ Not connected to server") + return + + try: + result = await self.session.call_tool(tool_name, arguments or {}) + print(f"\n🔧 Tool '{tool_name}' result:") + if hasattr(result, "content"): + for content in result.content: + if content.type == "text": + print(content.text) + else: + print(content) + else: + print(result) + except Exception as e: + print(f"❌ Failed to call tool '{tool_name}': {e}") + + async def interactive_loop(self): + """Run interactive command loop.""" + print("\n🎯 Interactive MCP Client") + print("Commands:") + print(" list - List available tools") + print(" call [args] - Call a tool") + print(" quit - Exit the client") + print() + + while True: + try: + command = input("mcp> ").strip() + + if not command: + continue + + if command == "quit": + break + + elif command == "list": + await self.list_tools() + + elif command.startswith("call "): + parts = command.split(maxsplit=2) + tool_name = parts[1] if len(parts) > 1 else "" + + if not tool_name: + print("❌ Please specify a tool name") + continue + + # Parse arguments (simple JSON-like format) + arguments: dict[str, Any] = {} + if len(parts) > 2: + import json + + try: + arguments = json.loads(parts[2]) + except json.JSONDecodeError: + print("❌ Invalid arguments format (expected JSON)") + continue + + await self.call_tool(tool_name, arguments) + + else: + print("❌ Unknown command. Try 'list', 'call ', or 'quit'") + + except KeyboardInterrupt: + print("\n\n👋 Goodbye!") + break + except EOFError: + break + + +async def main(): + """Main entry point.""" + # Default server URL - can be overridden with environment variable + # Most MCP streamable HTTP servers use /mcp as the endpoint + server_url = os.getenv("MCP_SERVER_PORT", 8000) + transport_type = os.getenv("MCP_TRANSPORT_TYPE", "streamable-http") + client_metadata_url = os.getenv("MCP_CLIENT_METADATA_URL") + server_url = ( + f"http://localhost:{server_url}/mcp" + if transport_type == "streamable-http" + else f"http://localhost:{server_url}/sse" + ) + + print("🚀 Simple MCP Auth Client") + print(f"Connecting to: {server_url}") + print(f"Transport type: {transport_type}") + if client_metadata_url: + print(f"Client metadata URL: {client_metadata_url}") + + # Start connection flow - OAuth will be handled automatically + client = SimpleAuthClient(server_url, transport_type, client_metadata_url) + await client.connect() + + +def cli(): + """CLI entry point for uv script.""" + asyncio.run(main()) + + +if __name__ == "__main__": + cli() diff --git a/examples/clients/simple-auth-client/pyproject.toml b/examples/clients/simple-auth-client/pyproject.toml new file mode 100644 index 0000000..f84d143 --- /dev/null +++ b/examples/clients/simple-auth-client/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "mcp-simple-auth-client" +version = "0.1.0" +description = "A simple OAuth client for the MCP simple-auth server" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "oauth", "client", "auth"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["click>=8.2.0", "mcp"] + +[project.scripts] +mcp-simple-auth-client = "mcp_simple_auth_client.main:cli" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_auth_client"] + +[tool.pyright] +include = ["mcp_simple_auth_client"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/clients/simple-chatbot/README.MD b/examples/clients/simple-chatbot/README.MD new file mode 100644 index 0000000..482109f --- /dev/null +++ b/examples/clients/simple-chatbot/README.MD @@ -0,0 +1,113 @@ +# MCP Simple Chatbot + +This example demonstrates how to integrate the Model Context Protocol (MCP) into a simple CLI chatbot. The implementation showcases MCP's flexibility by supporting multiple tools through MCP servers and is compatible with any LLM provider that follows OpenAI API standards. + +## Requirements + +- Python 3.10 +- `python-dotenv` +- `requests` +- `mcp` +- `uvicorn` + +## Installation + +1. **Install the dependencies:** + + ```bash + pip install -r requirements.txt + ``` + +2. **Set up environment variables:** + + Create a `.env` file in the root directory and add your API key: + + ```plaintext + LLM_API_KEY=your_api_key_here + ``` + + **Note:** The current implementation is configured to use the Groq API endpoint (`https://api.groq.com/openai/v1/chat/completions`) with the `llama-3.2-90b-vision-preview` model. If you plan to use a different LLM provider, you'll need to modify the `LLMClient` class in `main.py` to use the appropriate endpoint URL and model parameters. + +3. **Configure servers:** + + The `servers_config.json` follows the same structure as Claude Desktop, allowing for easy integration of multiple servers. + Here's an example: + + ```json + { + "mcpServers": { + "sqlite": { + "command": "uvx", + "args": ["mcp-server-sqlite", "--db-path", "./test.db"] + }, + "puppeteer": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-puppeteer"] + } + } + } + ``` + + Environment variables are supported as well. Pass them as you would with the Claude Desktop App. + + Example: + + ```json + { + "mcpServers": { + "server_name": { + "command": "uvx", + "args": ["mcp-server-name", "--additional-args"], + "env": { + "API_KEY": "your_api_key_here" + } + } + } + } + ``` + +## Usage + +1. **Run the client:** + + ```bash + python main.py + ``` + +2. **Interact with the assistant:** + + The assistant will automatically detect available tools and can respond to queries based on the tools provided by the configured servers. + +3. **Exit the session:** + + Type `quit` or `exit` to end the session. + +## Architecture + +- **Tool Discovery**: Tools are automatically discovered from configured servers. +- **System Prompt**: Tools are dynamically included in the system prompt, allowing the LLM to understand available capabilities. +- **Server Integration**: Supports any MCP-compatible server, tested with various server implementations including Uvicorn and Node.js. + +### Class Structure + +- **Configuration**: Manages environment variables and server configurations +- **Server**: Handles MCP server initialization, tool discovery, and execution +- **Tool**: Represents individual tools with their properties and formatting +- **LLMClient**: Manages communication with the LLM provider +- **ChatSession**: Orchestrates the interaction between user, LLM, and tools + +### Logic Flow + +1. **Tool Integration**: + - Tools are dynamically discovered from MCP servers + - Tool descriptions are automatically included in system prompt + - Tool execution is handled through standardized MCP protocol + +2. **Runtime Flow**: + - User input is received + - Input is sent to LLM with context of available tools + - LLM response is parsed: + - If it's a tool call → execute tool and return result + - If it's a direct response → return to user + - Tool results are sent back to LLM for interpretation + - Final response is presented to user diff --git a/examples/clients/simple-chatbot/mcp_simple_chatbot/.env.example b/examples/clients/simple-chatbot/mcp_simple_chatbot/.env.example new file mode 100644 index 0000000..39be363 --- /dev/null +++ b/examples/clients/simple-chatbot/mcp_simple_chatbot/.env.example @@ -0,0 +1 @@ +LLM_API_KEY=gsk_1234567890 diff --git a/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py b/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py new file mode 100644 index 0000000..72b1a6f --- /dev/null +++ b/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py @@ -0,0 +1,421 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import os +import shutil +from contextlib import AsyncExitStack +from typing import Any + +import httpx +from dotenv import load_dotenv +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +# Configure logging +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") + + +class Configuration: + """Manages configuration and environment variables for the MCP client.""" + + def __init__(self) -> None: + """Initialize configuration with environment variables.""" + self.load_env() + self.api_key = os.getenv("LLM_API_KEY") + + @staticmethod + def load_env() -> None: + """Load environment variables from .env file.""" + load_dotenv() + + @staticmethod + def load_config(file_path: str) -> dict[str, Any]: + """Load server configuration from JSON file. + + Args: + file_path: Path to the JSON configuration file. + + Returns: + Dict containing server configuration. + + Raises: + FileNotFoundError: If configuration file doesn't exist. + JSONDecodeError: If configuration file is invalid JSON. + """ + with open(file_path, "r") as f: + return json.load(f) + + @property + def llm_api_key(self) -> str: + """Get the LLM API key. + + Returns: + The API key as a string. + + Raises: + ValueError: If the API key is not found in environment variables. + """ + if not self.api_key: + raise ValueError("LLM_API_KEY not found in environment variables") + return self.api_key + + +class Server: + """Manages MCP server connections and tool execution.""" + + def __init__(self, name: str, config: dict[str, Any]) -> None: + self.name: str = name + self.config: dict[str, Any] = config + self.stdio_context: Any | None = None + self.session: ClientSession | None = None + self._cleanup_lock: asyncio.Lock = asyncio.Lock() + self.exit_stack: AsyncExitStack = AsyncExitStack() + + async def initialize(self) -> None: + """Initialize the server connection.""" + command = shutil.which("npx") if self.config["command"] == "npx" else self.config["command"] + if command is None: + raise ValueError("The command must be a valid string and cannot be None.") + + server_params = StdioServerParameters( + command=command, + args=self.config["args"], + env={**os.environ, **self.config["env"]} if self.config.get("env") else None, + ) + try: + stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params)) + read, write = stdio_transport + session = await self.exit_stack.enter_async_context(ClientSession(read, write)) + await session.initialize() + self.session = session + except Exception as e: + logging.error(f"Error initializing server {self.name}: {e}") + await self.cleanup() + raise + + async def list_tools(self) -> list[Tool]: + """List available tools from the server. + + Returns: + A list of available tools. + + Raises: + RuntimeError: If the server is not initialized. + """ + if not self.session: + raise RuntimeError(f"Server {self.name} not initialized") + + tools_response = await self.session.list_tools() + tools: list[Tool] = [] + + for item in tools_response: + if item[0] == "tools": + tools.extend(Tool(tool.name, tool.description, tool.input_schema, tool.title) for tool in item[1]) + + return tools + + async def execute_tool( + self, + tool_name: str, + arguments: dict[str, Any], + retries: int = 2, + delay: float = 1.0, + ) -> Any: + """Execute a tool with retry mechanism. + + Args: + tool_name: Name of the tool to execute. + arguments: Tool arguments. + retries: Number of retry attempts. + delay: Delay between retries in seconds. + + Returns: + Tool execution result. + + Raises: + RuntimeError: If server is not initialized. + Exception: If tool execution fails after all retries. + """ + if not self.session: + raise RuntimeError(f"Server {self.name} not initialized") + + attempt = 0 + while attempt < retries: + try: + logging.info(f"Executing {tool_name}...") + result = await self.session.call_tool(tool_name, arguments) + + return result + + except Exception as e: + attempt += 1 + logging.warning(f"Error executing tool: {e}. Attempt {attempt} of {retries}.") + if attempt < retries: + logging.info(f"Retrying in {delay} seconds...") + await asyncio.sleep(delay) + else: + logging.error("Max retries reached. Failing.") + raise + + async def cleanup(self) -> None: + """Clean up server resources.""" + async with self._cleanup_lock: + try: + await self.exit_stack.aclose() + self.session = None + self.stdio_context = None + except Exception as e: + logging.error(f"Error during cleanup of server {self.name}: {e}") + + +class Tool: + """Represents a tool with its properties and formatting.""" + + def __init__( + self, + name: str, + description: str, + input_schema: dict[str, Any], + title: str | None = None, + ) -> None: + self.name: str = name + self.title: str | None = title + self.description: str = description + self.input_schema: dict[str, Any] = input_schema + + def format_for_llm(self) -> str: + """Format tool information for LLM. + + Returns: + A formatted string describing the tool. + """ + args_desc: list[str] = [] + if "properties" in self.input_schema: + for param_name, param_info in self.input_schema["properties"].items(): + arg_desc = f"- {param_name}: {param_info.get('description', 'No description')}" + if param_name in self.input_schema.get("required", []): + arg_desc += " (required)" + args_desc.append(arg_desc) + + # Build the formatted output with title as a separate field + output = f"Tool: {self.name}\n" + + # Add human-readable title if available + if self.title: + output += f"User-readable title: {self.title}\n" + + output += f"""Description: {self.description} +Arguments: +{chr(10).join(args_desc)} +""" + + return output + + +class LLMClient: + """Manages communication with the LLM provider.""" + + def __init__(self, api_key: str) -> None: + self.api_key: str = api_key + + def get_response(self, messages: list[dict[str, str]]) -> str: + """Get a response from the LLM. + + Args: + messages: A list of message dictionaries. + + Returns: + The LLM's response as a string. + + Raises: + httpx.RequestError: If the request to the LLM fails. + """ + url = "https://api.groq.com/openai/v1/chat/completions" + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + } + payload = { + "messages": messages, + "model": "meta-llama/llama-4-scout-17b-16e-instruct", + "temperature": 0.7, + "max_tokens": 4096, + "top_p": 1, + "stream": False, + "stop": None, + } + + try: + with httpx.Client() as client: + response = client.post(url, headers=headers, json=payload) + response.raise_for_status() + data = response.json() + return data["choices"][0]["message"]["content"] + + except httpx.RequestError as e: + error_message = f"Error getting LLM response: {str(e)}" + logging.error(error_message) + + if isinstance(e, httpx.HTTPStatusError): + status_code = e.response.status_code + logging.error(f"Status code: {status_code}") + logging.error(f"Response details: {e.response.text}") + + return f"I encountered an error: {error_message}. Please try again or rephrase your request." + + +class ChatSession: + """Orchestrates the interaction between user, LLM, and tools.""" + + def __init__(self, servers: list[Server], llm_client: LLMClient) -> None: + self.servers: list[Server] = servers + self.llm_client: LLMClient = llm_client + + async def cleanup_servers(self) -> None: + """Clean up all servers properly.""" + for server in reversed(self.servers): + try: + await server.cleanup() + except Exception as e: + logging.warning(f"Warning during final cleanup: {e}") + + async def process_llm_response(self, llm_response: str) -> str: + """Process the LLM response and execute tools if needed. + + Args: + llm_response: The response from the LLM. + + Returns: + The result of tool execution or the original response. + """ + import json + + def _clean_json_string(json_string: str) -> str: + """Remove ```json ... ``` or ``` ... ``` wrappers if the LLM response is fenced.""" + import re + + pattern = r"^```(?:\s*json)?\s*(.*?)\s*```$" + return re.sub(pattern, r"\1", json_string, flags=re.DOTALL | re.IGNORECASE).strip() + + try: + tool_call = json.loads(_clean_json_string(llm_response)) + if "tool" in tool_call and "arguments" in tool_call: + logging.info(f"Executing tool: {tool_call['tool']}") + logging.info(f"With arguments: {tool_call['arguments']}") + + for server in self.servers: + tools = await server.list_tools() + if any(tool.name == tool_call["tool"] for tool in tools): + try: + result = await server.execute_tool(tool_call["tool"], tool_call["arguments"]) + + if isinstance(result, dict) and "progress" in result: + progress = result["progress"] # type: ignore + total = result["total"] # type: ignore + percentage = (progress / total) * 100 # type: ignore + logging.info(f"Progress: {progress}/{total} ({percentage:.1f}%)") + + return f"Tool execution result: {result}" + except Exception as e: + error_msg = f"Error executing tool: {str(e)}" + logging.error(error_msg) + return error_msg + + return f"No server found with tool: {tool_call['tool']}" + return llm_response + except json.JSONDecodeError: + return llm_response + + async def start(self) -> None: + """Main chat session handler.""" + try: + for server in self.servers: + try: + await server.initialize() + except Exception as e: + logging.error(f"Failed to initialize server: {e}") + await self.cleanup_servers() + return + + all_tools: list[Tool] = [] + for server in self.servers: + tools = await server.list_tools() + all_tools.extend(tools) + + tools_description = "\n".join([tool.format_for_llm() for tool in all_tools]) + + system_message = ( + "You are a helpful assistant with access to these tools:\n\n" + f"{tools_description}\n" + "Choose the appropriate tool based on the user's question. " + "If no tool is needed, reply directly.\n\n" + "IMPORTANT: When you need to use a tool, you must ONLY respond with " + "the exact JSON object format below, nothing else:\n" + "{\n" + ' "tool": "tool-name",\n' + ' "arguments": {\n' + ' "argument-name": "value"\n' + " }\n" + "}\n\n" + "After receiving a tool's response:\n" + "1. Transform the raw data into a natural, conversational response\n" + "2. Keep responses concise but informative\n" + "3. Focus on the most relevant information\n" + "4. Use appropriate context from the user's question\n" + "5. Avoid simply repeating the raw data\n\n" + "Please use only the tools that are explicitly defined above." + ) + + messages = [{"role": "system", "content": system_message}] + + while True: + try: + user_input = input("You: ").strip().lower() + if user_input in ["quit", "exit"]: + logging.info("\nExiting...") + break + + messages.append({"role": "user", "content": user_input}) + + llm_response = self.llm_client.get_response(messages) + logging.info("\nAssistant: %s", llm_response) + + result = await self.process_llm_response(llm_response) + + if result != llm_response: + messages.append({"role": "assistant", "content": llm_response}) + messages.append({"role": "system", "content": result}) + + final_response = self.llm_client.get_response(messages) + logging.info("\nFinal response: %s", final_response) + messages.append({"role": "assistant", "content": final_response}) + else: + messages.append({"role": "assistant", "content": llm_response}) + + except KeyboardInterrupt: + logging.info("\nExiting...") + break + + finally: + await self.cleanup_servers() + + +async def run() -> None: + """Initialize and run the chat session.""" + config = Configuration() + server_config = config.load_config("servers_config.json") + servers = [Server(name, srv_config) for name, srv_config in server_config["mcpServers"].items()] + llm_client = LLMClient(config.llm_api_key) + chat_session = ChatSession(servers, llm_client) + await chat_session.start() + + +def main() -> None: + asyncio.run(run()) + + +if __name__ == "__main__": + main() diff --git a/examples/clients/simple-chatbot/mcp_simple_chatbot/requirements.txt b/examples/clients/simple-chatbot/mcp_simple_chatbot/requirements.txt new file mode 100644 index 0000000..2292072 --- /dev/null +++ b/examples/clients/simple-chatbot/mcp_simple_chatbot/requirements.txt @@ -0,0 +1,4 @@ +python-dotenv>=1.0.0 +requests>=2.31.0 +mcp>=1.0.0 +uvicorn>=0.32.1 diff --git a/examples/clients/simple-chatbot/mcp_simple_chatbot/servers_config.json b/examples/clients/simple-chatbot/mcp_simple_chatbot/servers_config.json new file mode 100644 index 0000000..3a92d05 --- /dev/null +++ b/examples/clients/simple-chatbot/mcp_simple_chatbot/servers_config.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "sqlite": { + "command": "uvx", + "args": ["mcp-server-sqlite", "--db-path", "./test.db"] + }, + "puppeteer": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-puppeteer"] + } + } +} diff --git a/examples/clients/simple-chatbot/mcp_simple_chatbot/test.db b/examples/clients/simple-chatbot/mcp_simple_chatbot/test.db new file mode 100644 index 0000000..d08dabc Binary files /dev/null and b/examples/clients/simple-chatbot/mcp_simple_chatbot/test.db differ diff --git a/examples/clients/simple-chatbot/pyproject.toml b/examples/clients/simple-chatbot/pyproject.toml new file mode 100644 index 0000000..2d72057 --- /dev/null +++ b/examples/clients/simple-chatbot/pyproject.toml @@ -0,0 +1,47 @@ +[project] +name = "mcp-simple-chatbot" +version = "0.1.0" +description = "A simple CLI chatbot using the Model Context Protocol (MCP)" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "chatbot", "cli"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = [ + "python-dotenv>=1.0.0", + "mcp", + "uvicorn>=0.32.1", +] + +[project.scripts] +mcp-simple-chatbot = "mcp_simple_chatbot.main:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_chatbot"] + +[tool.pyright] +include = ["mcp_simple_chatbot"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/clients/sse-polling-client/README.md b/examples/clients/sse-polling-client/README.md new file mode 100644 index 0000000..78449aa --- /dev/null +++ b/examples/clients/sse-polling-client/README.md @@ -0,0 +1,30 @@ +# MCP SSE Polling Demo Client + +Demonstrates client-side auto-reconnect for the SSE polling pattern (SEP-1699). + +## Features + +- Connects to SSE polling demo server +- Automatically reconnects when server closes SSE stream +- Resumes from Last-Event-ID to avoid missing messages +- Respects server-provided retry interval + +## Usage + +```bash +# First start the server: +uv run mcp-sse-polling-demo --port 3000 + +# Then run this client: +uv run mcp-sse-polling-client --url http://localhost:3000/mcp + +# Custom options: +uv run mcp-sse-polling-client --url http://localhost:3000/mcp --items 20 --checkpoint-every 5 +``` + +## Options + +- `--url`: Server URL (default: ) +- `--items`: Number of items to process (default: 10) +- `--checkpoint-every`: Checkpoint interval (default: 3) +- `--log-level`: Logging level (default: DEBUG) diff --git a/examples/clients/sse-polling-client/mcp_sse_polling_client/__init__.py b/examples/clients/sse-polling-client/mcp_sse_polling_client/__init__.py new file mode 100644 index 0000000..ee69b32 --- /dev/null +++ b/examples/clients/sse-polling-client/mcp_sse_polling_client/__init__.py @@ -0,0 +1 @@ +"""SSE Polling Demo Client - demonstrates auto-reconnect for long-running tasks.""" diff --git a/examples/clients/sse-polling-client/mcp_sse_polling_client/main.py b/examples/clients/sse-polling-client/mcp_sse_polling_client/main.py new file mode 100644 index 0000000..e91ed9d --- /dev/null +++ b/examples/clients/sse-polling-client/mcp_sse_polling_client/main.py @@ -0,0 +1,102 @@ +"""SSE Polling Demo Client + +Demonstrates the client-side auto-reconnect for SSE polling pattern. + +This client connects to the SSE Polling Demo server and calls process_batch, +which triggers periodic server-side stream closes. The client automatically +reconnects using Last-Event-ID and resumes receiving messages. + +Run with: + # First start the server: + uv run mcp-sse-polling-demo --port 3000 + + # Then run this client: + uv run mcp-sse-polling-client --url http://localhost:3000/mcp +""" + +import asyncio +import logging + +import click +from mcp import ClientSession +from mcp.client.streamable_http import streamable_http_client + + +async def run_demo(url: str, items: int, checkpoint_every: int) -> None: + """Run the SSE polling demo.""" + print(f"\n{'=' * 60}") + print("SSE Polling Demo Client") + print(f"{'=' * 60}") + print(f"Server URL: {url}") + print(f"Processing {items} items with checkpoints every {checkpoint_every}") + print(f"{'=' * 60}\n") + + async with streamable_http_client(url) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + # Initialize the connection + print("Initializing connection...") + await session.initialize() + print("Connected!\n") + + # List available tools + tools = await session.list_tools() + print(f"Available tools: {[t.name for t in tools.tools]}\n") + + # Call the process_batch tool + print(f"Calling process_batch(items={items}, checkpoint_every={checkpoint_every})...\n") + print("-" * 40) + + result = await session.call_tool( + "process_batch", + { + "items": items, + "checkpoint_every": checkpoint_every, + }, + ) + + print("-" * 40) + if result.content: + content = result.content[0] + text = getattr(content, "text", str(content)) + print(f"\nResult: {text}") + else: + print("\nResult: No content") + print(f"{'=' * 60}\n") + + +@click.command() +@click.option( + "--url", + default="http://localhost:3000/mcp", + help="Server URL", +) +@click.option( + "--items", + default=10, + help="Number of items to process", +) +@click.option( + "--checkpoint-every", + default=3, + help="Checkpoint interval", +) +@click.option( + "--log-level", + default="INFO", + help="Logging level", +) +def main(url: str, items: int, checkpoint_every: int, log_level: str) -> None: + """Run the SSE Polling Demo client.""" + logging.basicConfig( + level=getattr(logging, log_level.upper()), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + # Suppress noisy HTTP client logging + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("httpcore").setLevel(logging.WARNING) + + asyncio.run(run_demo(url, items, checkpoint_every)) + + +if __name__ == "__main__": + main() diff --git a/examples/clients/sse-polling-client/pyproject.toml b/examples/clients/sse-polling-client/pyproject.toml new file mode 100644 index 0000000..4db2985 --- /dev/null +++ b/examples/clients/sse-polling-client/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "mcp-sse-polling-client" +version = "0.1.0" +description = "Demo client for SSE polling with auto-reconnect" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "sse", "polling", "client"] +license = { text = "MIT" } +dependencies = ["click>=8.2.0", "mcp"] + +[project.scripts] +mcp-sse-polling-client = "mcp_sse_polling_client.main:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_sse_polling_client"] + +[tool.pyright] +include = ["mcp_sse_polling_client"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/mcpserver/complex_inputs.py b/examples/mcpserver/complex_inputs.py new file mode 100644 index 0000000..93a42d1 --- /dev/null +++ b/examples/mcpserver/complex_inputs.py @@ -0,0 +1,29 @@ +"""MCPServer Complex inputs Example + +Demonstrates validation via pydantic with complex models. +""" + +from typing import Annotated + +from pydantic import BaseModel, Field + +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("Shrimp Tank") + + +class ShrimpTank(BaseModel): + class Shrimp(BaseModel): + name: Annotated[str, Field(max_length=10)] + + shrimp: list[Shrimp] + + +@mcp.tool() +def name_shrimp( + tank: ShrimpTank, + # You can use pydantic Field in function signatures for validation. + extra_names: Annotated[list[str], Field(max_length=10)], +) -> list[str]: + """List all shrimp names in the tank""" + return [shrimp.name for shrimp in tank.shrimp] + extra_names diff --git a/examples/mcpserver/desktop.py b/examples/mcpserver/desktop.py new file mode 100644 index 0000000..8041845 --- /dev/null +++ b/examples/mcpserver/desktop.py @@ -0,0 +1,24 @@ +"""MCPServer Desktop Example + +A simple example that exposes the desktop directory as a resource. +""" + +from pathlib import Path + +from mcp.server.mcpserver import MCPServer + +# Create server +mcp = MCPServer("Demo") + + +@mcp.resource("dir://desktop") +def desktop() -> list[str]: + """List the files in the user's desktop""" + desktop = Path.home() / "Desktop" + return [str(f) for f in desktop.iterdir()] + + +@mcp.tool() +def sum(a: int, b: int) -> int: + """Add two numbers""" + return a + b diff --git a/examples/mcpserver/direct_call_tool_result_return.py b/examples/mcpserver/direct_call_tool_result_return.py new file mode 100644 index 0000000..c73e616 --- /dev/null +++ b/examples/mcpserver/direct_call_tool_result_return.py @@ -0,0 +1,22 @@ +"""MCPServer Echo Server with direct CallToolResult return""" + +from typing import Annotated + +from mcp_types import CallToolResult, TextContent +from pydantic import BaseModel + +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("Echo Server") + + +class EchoResponse(BaseModel): + text: str + + +@mcp.tool() +def echo(text: str) -> Annotated[CallToolResult, EchoResponse]: + """Echo the input text with structure and metadata""" + return CallToolResult( + content=[TextContent(type="text", text=text)], structured_content={"text": text}, _meta={"some": "metadata"} + ) diff --git a/examples/mcpserver/echo.py b/examples/mcpserver/echo.py new file mode 100644 index 0000000..501c470 --- /dev/null +++ b/examples/mcpserver/echo.py @@ -0,0 +1,28 @@ +"""MCPServer Echo Server""" + +from mcp.server.mcpserver import MCPServer + +# Create server +mcp = MCPServer("Echo Server") + + +@mcp.tool() +def echo_tool(text: str) -> str: + """Echo the input text""" + return text + + +@mcp.resource("echo://static") +def echo_resource() -> str: + return "Echo!" + + +@mcp.resource("echo://{text}") +def echo_template(text: str) -> str: + """Echo the input text""" + return f"Echo: {text}" + + +@mcp.prompt("echo") +def echo_prompt(text: str) -> str: + return text diff --git a/examples/mcpserver/icons_demo.py b/examples/mcpserver/icons_demo.py new file mode 100644 index 0000000..f50389f --- /dev/null +++ b/examples/mcpserver/icons_demo.py @@ -0,0 +1,56 @@ +"""MCPServer Icons Demo Server + +Demonstrates using icons with tools, resources, prompts, and implementation. +""" + +import base64 +from pathlib import Path + +from mcp.server.mcpserver import Icon, MCPServer + +# Load the icon file and convert to data URI +icon_path = Path(__file__).parent / "mcp.png" +icon_data = base64.standard_b64encode(icon_path.read_bytes()).decode() +icon_data_uri = f"data:image/png;base64,{icon_data}" + +icon_data = Icon(src=icon_data_uri, mime_type="image/png", sizes=["64x64"]) + +# Create server with icons in implementation +mcp = MCPServer( + "Icons Demo Server", website_url="https://github.com/modelcontextprotocol/python-sdk", icons=[icon_data] +) + + +@mcp.tool(icons=[icon_data]) +def demo_tool(message: str) -> str: + """A demo tool with an icon.""" + return message + + +@mcp.resource("demo://readme", icons=[icon_data]) +def readme_resource() -> str: + """A demo resource with an icon""" + return "This resource has an icon" + + +@mcp.prompt("prompt_with_icon", icons=[icon_data]) +def prompt_with_icon(text: str) -> str: + """A demo prompt with an icon""" + return text + + +@mcp.tool( + icons=[ + Icon(src=icon_data_uri, mime_type="image/png", sizes=["16x16"]), + Icon(src=icon_data_uri, mime_type="image/png", sizes=["32x32"]), + Icon(src=icon_data_uri, mime_type="image/png", sizes=["64x64"]), + ] +) +def multi_icon_tool(action: str) -> str: + """A tool demonstrating multiple icons.""" + return "multi_icon_tool" + + +if __name__ == "__main__": + # Run the server + mcp.run() diff --git a/examples/mcpserver/logging_and_progress.py b/examples/mcpserver/logging_and_progress.py new file mode 100644 index 0000000..b157f9d --- /dev/null +++ b/examples/mcpserver/logging_and_progress.py @@ -0,0 +1,31 @@ +"""MCPServer Echo Server that sends log messages and progress updates to the client""" + +import asyncio + +from mcp.server.mcpserver import Context, MCPServer + +# Create server +mcp = MCPServer("Echo Server with logging and progress updates") + + +@mcp.tool() +async def echo(text: str, ctx: Context) -> str: + """Echo the input text sending log messages and progress updates during processing.""" + await ctx.report_progress(progress=0, total=100) + await ctx.info("Starting to process echo for input: " + text) + + await asyncio.sleep(2) + + await ctx.info("Halfway through processing echo for input: " + text) + await ctx.report_progress(progress=50, total=100) + + await asyncio.sleep(2) + + await ctx.info("Finished processing echo for input: " + text) + await ctx.report_progress(progress=100, total=100) + + # Progress notifications are process asynchronously by the client. + # A small delay here helps ensure the last notification is processed by the client. + await asyncio.sleep(0.1) + + return text diff --git a/examples/mcpserver/mcp.png b/examples/mcpserver/mcp.png new file mode 100644 index 0000000..8e08571 Binary files /dev/null and b/examples/mcpserver/mcp.png differ diff --git a/examples/mcpserver/memory.py b/examples/mcpserver/memory.py new file mode 100644 index 0000000..fd0bd93 --- /dev/null +++ b/examples/mcpserver/memory.py @@ -0,0 +1,324 @@ +# /// script +# dependencies = ["pydantic-ai-slim[openai]", "asyncpg", "numpy", "pgvector"] +# /// + +# uv pip install 'pydantic-ai-slim[openai]' asyncpg numpy pgvector + +"""Recursive memory system inspired by the human brain's clustering of memories. +Uses OpenAI's 'text-embedding-3-small' model and pgvector for efficient +similarity search. +""" + +import asyncio +import math +import os +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Annotated, Self, TypeVar + +import asyncpg +import numpy as np +from openai import AsyncOpenAI +from pgvector.asyncpg import register_vector # Import register_vector +from pydantic import BaseModel, Field +from pydantic_ai import Agent + +from mcp.server.mcpserver import MCPServer + +MAX_DEPTH = 5 +SIMILARITY_THRESHOLD = 0.7 +DECAY_FACTOR = 0.99 +REINFORCEMENT_FACTOR = 1.1 + +DEFAULT_LLM_MODEL = "openai:gpt-4o" +DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small" + +T = TypeVar("T") + +mcp = MCPServer("memory") + +DB_DSN = "postgresql://postgres:postgres@localhost:54320/memory_db" +# reset memory with rm ~/.mcp/{USER}/memory/* +PROFILE_DIR = (Path.home() / ".mcp" / os.environ.get("USER", "anon") / "memory").resolve() +PROFILE_DIR.mkdir(parents=True, exist_ok=True) + + +def cosine_similarity(a: list[float], b: list[float]) -> float: + a_array = np.array(a, dtype=np.float64) + b_array = np.array(b, dtype=np.float64) + return np.dot(a_array, b_array) / (np.linalg.norm(a_array) * np.linalg.norm(b_array)) + + +async def do_ai( + user_prompt: str, + system_prompt: str, + result_type: type[T] | Annotated, + deps=None, +) -> T: + agent = Agent( + DEFAULT_LLM_MODEL, + system_prompt=system_prompt, + result_type=result_type, + ) + result = await agent.run(user_prompt, deps=deps) + return result.data + + +@dataclass +class Deps: + openai: AsyncOpenAI + pool: asyncpg.Pool + + +async def get_db_pool() -> asyncpg.Pool: + async def init(conn): + await conn.execute("CREATE EXTENSION IF NOT EXISTS vector;") + await register_vector(conn) + + pool = await asyncpg.create_pool(DB_DSN, init=init) + return pool + + +class MemoryNode(BaseModel): + id: int | None = None + content: str + summary: str = "" + importance: float = 1.0 + access_count: int = 0 + timestamp: float = Field(default_factory=lambda: datetime.now(timezone.utc).timestamp()) + embedding: list[float] + + @classmethod + async def from_content(cls, content: str, deps: Deps): + embedding = await get_embedding(content, deps) + return cls(content=content, embedding=embedding) + + async def save(self, deps: Deps): + async with deps.pool.acquire() as conn: + if self.id is None: + result = await conn.fetchrow( + """ + INSERT INTO memories (content, summary, importance, access_count, + timestamp, embedding) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id + """, + self.content, + self.summary, + self.importance, + self.access_count, + self.timestamp, + self.embedding, + ) + self.id = result["id"] + else: + await conn.execute( + """ + UPDATE memories + SET content = $1, summary = $2, importance = $3, + access_count = $4, timestamp = $5, embedding = $6 + WHERE id = $7 + """, + self.content, + self.summary, + self.importance, + self.access_count, + self.timestamp, + self.embedding, + self.id, + ) + + async def merge_with(self, other: Self, deps: Deps): + self.content = await do_ai( + f"{self.content}\n\n{other.content}", + "Combine the following two texts into a single, coherent text.", + str, + deps, + ) + self.importance += other.importance + self.access_count += other.access_count + self.embedding = [(a + b) / 2 for a, b in zip(self.embedding, other.embedding)] + self.summary = await do_ai(self.content, "Summarize the following text concisely.", str, deps) + await self.save(deps) + # Delete the merged node from the database + if other.id is not None: + await delete_memory(other.id, deps) + + def get_effective_importance(self): + return self.importance * (1 + math.log(self.access_count + 1)) + + +async def get_embedding(text: str, deps: Deps) -> list[float]: + embedding_response = await deps.openai.embeddings.create( + input=text, + model=DEFAULT_EMBEDDING_MODEL, + ) + return embedding_response.data[0].embedding + + +async def delete_memory(memory_id: int, deps: Deps): + async with deps.pool.acquire() as conn: + await conn.execute("DELETE FROM memories WHERE id = $1", memory_id) + + +async def add_memory(content: str, deps: Deps): + new_memory = await MemoryNode.from_content(content, deps) + await new_memory.save(deps) + + similar_memories = await find_similar_memories(new_memory.embedding, deps) + for memory in similar_memories: + if memory.id != new_memory.id: + await new_memory.merge_with(memory, deps) + + await update_importance(new_memory.embedding, deps) + + await prune_memories(deps) + + return f"Remembered: {content}" + + +async def find_similar_memories(embedding: list[float], deps: Deps) -> list[MemoryNode]: + async with deps.pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT id, content, summary, importance, access_count, timestamp, embedding + FROM memories + ORDER BY embedding <-> $1 + LIMIT 5 + """, + embedding, + ) + memories = [ + MemoryNode( + id=row["id"], + content=row["content"], + summary=row["summary"], + importance=row["importance"], + access_count=row["access_count"], + timestamp=row["timestamp"], + embedding=row["embedding"], + ) + for row in rows + ] + return memories + + +async def update_importance(user_embedding: list[float], deps: Deps): + async with deps.pool.acquire() as conn: + rows = await conn.fetch("SELECT id, importance, access_count, embedding FROM memories") + for row in rows: + memory_embedding = row["embedding"] + similarity = cosine_similarity(user_embedding, memory_embedding) + if similarity > SIMILARITY_THRESHOLD: + new_importance = row["importance"] * REINFORCEMENT_FACTOR + new_access_count = row["access_count"] + 1 + else: + new_importance = row["importance"] * DECAY_FACTOR + new_access_count = row["access_count"] + await conn.execute( + """ + UPDATE memories + SET importance = $1, access_count = $2 + WHERE id = $3 + """, + new_importance, + new_access_count, + row["id"], + ) + + +async def prune_memories(deps: Deps): + async with deps.pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT id, importance, access_count + FROM memories + ORDER BY importance DESC + OFFSET $1 + """, + MAX_DEPTH, + ) + for row in rows: + await conn.execute("DELETE FROM memories WHERE id = $1", row["id"]) + + +async def display_memory_tree(deps: Deps) -> str: + async with deps.pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT content, summary, importance, access_count + FROM memories + ORDER BY importance DESC + LIMIT $1 + """, + MAX_DEPTH, + ) + result = "" + for row in rows: + effective_importance = row["importance"] * (1 + math.log(row["access_count"] + 1)) + summary = row["summary"] or row["content"] + result += f"- {summary} (Importance: {effective_importance:.2f})\n" + return result + + +@mcp.tool() +async def remember( + contents: list[str] = Field(description="List of observations or memories to store"), +): + deps = Deps(openai=AsyncOpenAI(), pool=await get_db_pool()) + try: + return "\n".join(await asyncio.gather(*[add_memory(content, deps) for content in contents])) + finally: + await deps.pool.close() + + +@mcp.tool() +async def read_profile() -> str: + deps = Deps(openai=AsyncOpenAI(), pool=await get_db_pool()) + profile = await display_memory_tree(deps) + await deps.pool.close() + return profile + + +async def initialize_database(): + pool = await asyncpg.create_pool("postgresql://postgres:postgres@localhost:54320/postgres") + try: + async with pool.acquire() as conn: + await conn.execute(""" + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = 'memory_db' + AND pid <> pg_backend_pid(); + """) + await conn.execute("DROP DATABASE IF EXISTS memory_db;") + await conn.execute("CREATE DATABASE memory_db;") + finally: + await pool.close() + + pool = await asyncpg.create_pool(DB_DSN) + try: + async with pool.acquire() as conn: + await conn.execute("CREATE EXTENSION IF NOT EXISTS vector;") + + await register_vector(conn) + + await conn.execute(""" + CREATE TABLE IF NOT EXISTS memories ( + id SERIAL PRIMARY KEY, + content TEXT NOT NULL, + summary TEXT, + importance REAL NOT NULL, + access_count INT NOT NULL, + timestamp DOUBLE PRECISION NOT NULL, + embedding vector(1536) NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_memories_embedding ON memories + USING hnsw (embedding vector_l2_ops); + """) + finally: + await pool.close() + + +if __name__ == "__main__": + asyncio.run(initialize_database()) diff --git a/examples/mcpserver/parameter_descriptions.py b/examples/mcpserver/parameter_descriptions.py new file mode 100644 index 0000000..59a1caf --- /dev/null +++ b/examples/mcpserver/parameter_descriptions.py @@ -0,0 +1,19 @@ +"""MCPServer Example showing parameter descriptions""" + +from pydantic import Field + +from mcp.server.mcpserver import MCPServer + +# Create server +mcp = MCPServer("Parameter Descriptions Server") + + +@mcp.tool() +def greet_user( + name: str = Field(description="The name of the person to greet"), + title: str = Field(description="Optional title like Mr/Ms/Dr", default=""), + times: int = Field(description="Number of times to repeat the greeting", default=1), +) -> str: + """Greet a user with optional title and repetition""" + greeting = f"Hello {title + ' ' if title else ''}{name}!" + return "\n".join([greeting] * times) diff --git a/examples/mcpserver/readme-quickstart.py b/examples/mcpserver/readme-quickstart.py new file mode 100644 index 0000000..864b774 --- /dev/null +++ b/examples/mcpserver/readme-quickstart.py @@ -0,0 +1,18 @@ +from mcp.server.mcpserver import MCPServer + +# Create an MCP server +mcp = MCPServer("Demo") + + +# Add an addition tool +@mcp.tool() +def sum(a: int, b: int) -> int: + """Add two numbers""" + return a + b + + +# Add a dynamic greeting resource +@mcp.resource("greeting://{name}") +def get_greeting(name: str) -> str: + """Get a personalized greeting""" + return f"Hello, {name}!" diff --git a/examples/mcpserver/screenshot.py b/examples/mcpserver/screenshot.py new file mode 100644 index 0000000..e7b3ee6 --- /dev/null +++ b/examples/mcpserver/screenshot.py @@ -0,0 +1,27 @@ +"""MCPServer Screenshot Example + +Give Claude a tool to capture and view screenshots. +""" + +import io + +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.utilities.types import Image + +# Create server +mcp = MCPServer("Screenshot Demo") + + +@mcp.tool() +def take_screenshot() -> Image: + """Take a screenshot of the user's screen and return it as an image. Use + this tool anytime the user wants you to look at something they're doing. + """ + import pyautogui + + buffer = io.BytesIO() + + # if the file exceeds ~1MB, it will be rejected by Claude + screenshot = pyautogui.screenshot() + screenshot.convert("RGB").save(buffer, format="JPEG", quality=60, optimize=True) + return Image(data=buffer.getvalue(), format="jpeg") diff --git a/examples/mcpserver/simple_echo.py b/examples/mcpserver/simple_echo.py new file mode 100644 index 0000000..3d8142a --- /dev/null +++ b/examples/mcpserver/simple_echo.py @@ -0,0 +1,12 @@ +"""MCPServer Echo Server""" + +from mcp.server.mcpserver import MCPServer + +# Create server +mcp = MCPServer("Echo Server") + + +@mcp.tool() +def echo(text: str) -> str: + """Echo the input text""" + return text diff --git a/examples/mcpserver/text_me.py b/examples/mcpserver/text_me.py new file mode 100644 index 0000000..7aeb543 --- /dev/null +++ b/examples/mcpserver/text_me.py @@ -0,0 +1,67 @@ +# /// script +# dependencies = [] +# /// + +"""MCPServer Text Me Server +-------------------------------- +This defines a simple MCPServer server that sends a text message to a phone number via https://surgemsg.com/. + +To run this example, create a `.env` file with the following values: + +SURGE_API_KEY=... +SURGE_ACCOUNT_ID=... +SURGE_MY_PHONE_NUMBER=... +SURGE_MY_FIRST_NAME=... +SURGE_MY_LAST_NAME=... + +Visit https://surgemsg.com/ and click "Get Started" to obtain these values. +""" + +from typing import Annotated + +import httpx +from pydantic import BeforeValidator +from pydantic_settings import BaseSettings, SettingsConfigDict + +from mcp.server.mcpserver import MCPServer + + +class SurgeSettings(BaseSettings): + model_config: SettingsConfigDict = SettingsConfigDict(env_prefix="SURGE_", env_file=".env") + + api_key: str + account_id: str + my_phone_number: Annotated[str, BeforeValidator(lambda v: "+" + v if not v.startswith("+") else v)] + my_first_name: str + my_last_name: str + + +# Create server +mcp = MCPServer("Text me") +surge_settings = SurgeSettings() # type: ignore + + +@mcp.tool(name="textme", description="Send a text message to me") +def text_me(text_content: str) -> str: + """Send a text message to a phone number via https://surgemsg.com/""" + with httpx.Client() as client: + response = client.post( + "https://api.surgemsg.com/messages", + headers={ + "Authorization": f"Bearer {surge_settings.api_key}", + "Surge-Account": surge_settings.account_id, + "Content-Type": "application/json", + }, + json={ + "body": text_content, + "conversation": { + "contact": { + "first_name": surge_settings.my_first_name, + "last_name": surge_settings.my_last_name, + "phone_number": surge_settings.my_phone_number, + } + }, + }, + ) + response.raise_for_status() + return f"Message sent: {text_content}" diff --git a/examples/mcpserver/unicode_example.py b/examples/mcpserver/unicode_example.py new file mode 100644 index 0000000..012633e --- /dev/null +++ b/examples/mcpserver/unicode_example.py @@ -0,0 +1,59 @@ +"""Example MCPServer server that uses Unicode characters in various places to help test +Unicode handling in tools and inspectors. +""" + +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer() + + +@mcp.tool(description="🌟 A tool that uses various Unicode characters in its description: á é í ó ú ñ 漢字 🎉") +def hello_unicode(name: str = "世界", greeting: str = "¡Hola") -> str: + """A simple tool that demonstrates Unicode handling in: + - Tool description (emojis, accents, CJK characters) + - Parameter defaults (CJK characters) + - Return values (Spanish punctuation, emojis) + """ + return f"{greeting}, {name}! 👋" + + +@mcp.tool(description="🎨 Tool that returns a list of emoji categories") +def list_emoji_categories() -> list[str]: + """Returns a list of emoji categories with emoji examples.""" + return [ + "😀 Smileys & Emotion", + "👋 People & Body", + "🐶 Animals & Nature", + "🍎 Food & Drink", + "⚽ Activities", + "🌍 Travel & Places", + "💡 Objects", + "❤️ Symbols", + "🚩 Flags", + ] + + +@mcp.tool(description="🔤 Tool that returns text in different scripts") +def multilingual_hello() -> str: + """Returns hello in different scripts and writing systems.""" + return "\n".join( + [ + "English: Hello!", + "Spanish: ¡Hola!", + "French: Bonjour!", + "German: Grüß Gott!", + "Russian: Привет!", + "Greek: Γεια σας!", + "Hebrew: !שָׁלוֹם", + "Arabic: !مرحبا", + "Hindi: नमस्ते!", + "Chinese: 你好!", + "Japanese: こんにちは!", + "Korean: 안녕하세요!", + "Thai: สวัสดี!", + ] + ) + + +if __name__ == "__main__": + mcp.run() diff --git a/examples/mcpserver/weather_structured.py b/examples/mcpserver/weather_structured.py new file mode 100644 index 0000000..958c7d3 --- /dev/null +++ b/examples/mcpserver/weather_structured.py @@ -0,0 +1,224 @@ +"""MCPServer Weather Example with Structured Output + +Demonstrates how to use structured output with tools to return +well-typed, validated data that clients can easily process. +""" + +import asyncio +import json +import sys +from dataclasses import dataclass +from datetime import datetime +from typing import TypedDict + +from pydantic import BaseModel, Field + +from mcp.client import Client +from mcp.server.mcpserver import MCPServer + +# Create server +mcp = MCPServer("Weather Service") + + +# Example 1: Using a Pydantic model for structured output +class WeatherData(BaseModel): + """Structured weather data response""" + + temperature: float = Field(description="Temperature in Celsius") + humidity: float = Field(description="Humidity percentage (0-100)") + condition: str = Field(description="Weather condition (sunny, cloudy, rainy, etc.)") + wind_speed: float = Field(description="Wind speed in km/h") + location: str = Field(description="Location name") + timestamp: datetime = Field(default_factory=datetime.now, description="Observation time") + + +@mcp.tool() +def get_weather(city: str) -> WeatherData: + """Get current weather for a city with full structured data""" + # In a real implementation, this would fetch from a weather API + return WeatherData(temperature=22.5, humidity=65.0, condition="partly cloudy", wind_speed=12.3, location=city) + + +# Example 2: Using TypedDict for a simpler structure +class WeatherSummary(TypedDict): + """Simple weather summary""" + + city: str + temp_c: float + description: str + + +@mcp.tool() +def get_weather_summary(city: str) -> WeatherSummary: + """Get a brief weather summary for a city""" + return WeatherSummary(city=city, temp_c=22.5, description="Partly cloudy with light breeze") + + +# Example 3: Using dict[str, Any] for flexible schemas +@mcp.tool() +def get_weather_metrics(cities: list[str]) -> dict[str, dict[str, float]]: + """Get weather metrics for multiple cities + + Returns a dictionary mapping city names to their metrics + """ + # Returns nested dictionaries with weather metrics + return { + city: {"temperature": 20.0 + i * 2, "humidity": 60.0 + i * 5, "pressure": 1013.0 + i * 0.5} + for i, city in enumerate(cities) + } + + +# Example 4: Using dataclass for weather alerts +@dataclass +class WeatherAlert: + """Weather alert information""" + + severity: str # "low", "medium", "high" + title: str + description: str + affected_areas: list[str] + valid_until: datetime + + +@mcp.tool() +def get_weather_alerts(region: str) -> list[WeatherAlert]: + """Get active weather alerts for a region""" + # In production, this would fetch real alerts + if region.lower() == "california": + return [ + WeatherAlert( + severity="high", + title="Heat Wave Warning", + description="Temperatures expected to exceed 40 degrees", + affected_areas=["Los Angeles", "San Diego", "Riverside"], + valid_until=datetime(2024, 7, 15, 18, 0), + ), + WeatherAlert( + severity="medium", + title="Air Quality Advisory", + description="Poor air quality due to wildfire smoke", + affected_areas=["San Francisco Bay Area"], + valid_until=datetime(2024, 7, 14, 12, 0), + ), + ] + return [] + + +# Example 5: Returning primitives with structured output +@mcp.tool() +def get_temperature(city: str, unit: str = "celsius") -> float: + """Get just the temperature for a city + + When returning primitives as structured output, + the result is wrapped in {"result": value} + """ + base_temp = 22.5 + if unit.lower() == "fahrenheit": + return base_temp * 9 / 5 + 32 + return base_temp + + +# Example 6: Weather statistics with nested models +class DailyStats(BaseModel): + """Statistics for a single day""" + + high: float + low: float + mean: float + + +class WeatherStats(BaseModel): + """Weather statistics over a period""" + + location: str + period_days: int + temperature: DailyStats + humidity: DailyStats + precipitation_mm: float = Field(description="Total precipitation in millimeters") + + +@mcp.tool() +def get_weather_stats(city: str, days: int = 7) -> WeatherStats: + """Get weather statistics for the past N days""" + return WeatherStats( + location=city, + period_days=days, + temperature=DailyStats(high=28.5, low=15.2, mean=21.8), + humidity=DailyStats(high=85.0, low=45.0, mean=65.0), + precipitation_mm=12.4, + ) + + +if __name__ == "__main__": + + async def test() -> None: + """Test the tools by calling them through the server as a client would""" + print("Testing Weather Service Tools (via MCP protocol)\n") + print("=" * 80) + + async with Client(mcp) as client: + # Test get_weather + result = await client.call_tool("get_weather", {"city": "London"}) + print("\nWeather in London:") + print(json.dumps(result.structured_content, indent=2)) + + # Test get_weather_summary + result = await client.call_tool("get_weather_summary", {"city": "Paris"}) + print("\nWeather summary for Paris:") + print(json.dumps(result.structured_content, indent=2)) + + # Test get_weather_metrics + result = await client.call_tool("get_weather_metrics", {"cities": ["Tokyo", "Sydney", "Mumbai"]}) + print("\nWeather metrics:") + print(json.dumps(result.structured_content, indent=2)) + + # Test get_weather_alerts + result = await client.call_tool("get_weather_alerts", {"region": "California"}) + print("\nWeather alerts for California:") + print(json.dumps(result.structured_content, indent=2)) + + # Test get_temperature + result = await client.call_tool("get_temperature", {"city": "Berlin", "unit": "fahrenheit"}) + print("\nTemperature in Berlin:") + print(json.dumps(result.structured_content, indent=2)) + + # Test get_weather_stats + result = await client.call_tool("get_weather_stats", {"city": "Seattle", "days": 30}) + print("\nWeather stats for Seattle (30 days):") + print(json.dumps(result.structured_content, indent=2)) + + # Also show the text content for comparison + print("\nText content for last result:") + for content in result.content: + if content.type == "text": + print(content.text) + + async def print_schemas() -> None: + """Print all tool schemas""" + print("Tool Schemas for Weather Service\n") + print("=" * 80) + + tools = await mcp.list_tools() + for tool in tools: + print(f"\nTool: {tool.name}") + print(f"Description: {tool.description}") + print("Input Schema:") + print(json.dumps(tool.input_schema, indent=2)) + + if tool.output_schema: + print("Output Schema:") + print(json.dumps(tool.output_schema, indent=2)) + else: + print("Output Schema: None (returns unstructured content)") + + print("-" * 80) + + # Check command line arguments + if len(sys.argv) > 1 and sys.argv[1] == "--schemas": + asyncio.run(print_schemas()) + else: + print("Usage:") + print(" python weather_structured.py # Run tool tests") + print(" python weather_structured.py --schemas # Print tool schemas") + print() + asyncio.run(test()) diff --git a/examples/pyproject.toml b/examples/pyproject.toml new file mode 100644 index 0000000..7b01095 --- /dev/null +++ b/examples/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "mcp-example-stories" +version = "0.0.0" +description = "Self-verifying example suite for the MCP Python SDK (dev-only, not published)" +requires-python = ">=3.10" +dependencies = [ + "mcp", + "tomli>=2.0; python_version < '3.11'", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["stories"] diff --git a/examples/servers/everything-server/README.md b/examples/servers/everything-server/README.md new file mode 100644 index 0000000..3512665 --- /dev/null +++ b/examples/servers/everything-server/README.md @@ -0,0 +1,42 @@ +# MCP Everything Server + +A comprehensive MCP server implementing all protocol features for conformance testing. + +## Overview + +The Everything Server is a reference implementation that demonstrates all features of the Model Context Protocol (MCP). It is designed to be used with the [MCP Conformance Test Framework](https://github.com/modelcontextprotocol/conformance) to validate MCP client and server implementations. + +## Installation + +From the python-sdk root directory: + +```bash +uv sync --frozen +``` + +## Usage + +### Running the Server + +Start the server with default settings (port 3001): + +```bash +uv run -m mcp_everything_server +``` + +Or with custom options: + +```bash +uv run -m mcp_everything_server --port 3001 --log-level DEBUG +``` + +The server will be available at: `http://localhost:3001/mcp` + +### Command-Line Options + +- `--port` - Port to listen on (default: 3001) +- `--log-level` - Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL (default: INFO) + +## Running Conformance Tests + +See the [MCP Conformance Test Framework](https://github.com/modelcontextprotocol/conformance) for instructions on running conformance tests against this server. diff --git a/examples/servers/everything-server/mcp_everything_server/__init__.py b/examples/servers/everything-server/mcp_everything_server/__init__.py new file mode 100644 index 0000000..d539062 --- /dev/null +++ b/examples/servers/everything-server/mcp_everything_server/__init__.py @@ -0,0 +1,3 @@ +"""MCP Everything Server - Comprehensive conformance test server.""" + +__version__ = "0.1.0" diff --git a/examples/servers/everything-server/mcp_everything_server/__main__.py b/examples/servers/everything-server/mcp_everything_server/__main__.py new file mode 100644 index 0000000..2eff688 --- /dev/null +++ b/examples/servers/everything-server/mcp_everything_server/__main__.py @@ -0,0 +1,6 @@ +"""CLI entry point for the MCP Everything Server.""" + +from .server import main + +if __name__ == "__main__": + main() diff --git a/examples/servers/everything-server/mcp_everything_server/server.py b/examples/servers/everything-server/mcp_everything_server/server.py new file mode 100644 index 0000000..4b56a67 --- /dev/null +++ b/examples/servers/everything-server/mcp_everything_server/server.py @@ -0,0 +1,787 @@ +#!/usr/bin/env python3 +"""MCP Everything Server - Conformance Test Server + +Server implementing all MCP features for conformance testing based on Conformance Server Specification. +""" + +import asyncio +import base64 +import json +import logging +from typing import Annotated, Any + +import click +from mcp.server import ServerRequestContext +from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity +from mcp.server.mcpserver.prompts.base import Prompt, UserMessage +from mcp.server.streamable_http import EventCallback, EventMessage, EventStore +from mcp.shared.exceptions import MCPError +from mcp_types import ( + AudioContent, + Completion, + CompletionArgument, + CompletionContext, + CreateMessageRequest, + CreateMessageRequestParams, + CreateMessageResult, + ElicitRequest, + ElicitRequestFormParams, + ElicitResult, + EmbeddedResource, + EmptyResult, + ImageContent, + InputRequest, + InputRequiredResult, + JSONRPCMessage, + ListRootsRequest, + ListRootsResult, + PromptReference, + ResourceTemplateReference, + SamplingMessage, + SetLevelRequestParams, + SubscribeRequestParams, + TextContent, + TextResourceContents, + UnsubscribeRequestParams, +) +from mcp_types.jsonrpc import MISSING_REQUIRED_CLIENT_CAPABILITY +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + +# Type aliases for event store +StreamId = str +EventId = str + + +class InMemoryEventStore(EventStore): + """Simple in-memory event store for SSE resumability testing.""" + + def __init__(self) -> None: + self._events: list[tuple[StreamId, EventId, JSONRPCMessage | None]] = [] + self._event_id_counter = 0 + + async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId: + """Store an event and return its ID.""" + self._event_id_counter += 1 + event_id = str(self._event_id_counter) + self._events.append((stream_id, event_id, message)) + return event_id + + async def replay_events_after(self, last_event_id: EventId, send_callback: EventCallback) -> StreamId | None: + """Replay events after the specified ID.""" + target_stream_id = None + for stream_id, event_id, _ in self._events: + if event_id == last_event_id: + target_stream_id = stream_id + break + if target_stream_id is None: + return None + last_event_id_int = int(last_event_id) + for stream_id, event_id, message in self._events: + if stream_id == target_stream_id and int(event_id) > last_event_id_int: + # Skip priming events (None message) + if message is not None: + await send_callback(EventMessage(message, event_id)) + return target_stream_id + + +# Test data +TEST_IMAGE_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==" +TEST_AUDIO_BASE64 = "UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA=" + +# Server state +resource_subscriptions: set[str] = set() +watched_resource_content = "Watched resource content" + +# Create event store for SSE resumability (SEP-1699) +event_store = InMemoryEventStore() + +# Fixed fixture key (RequestStateSecurity requires at least 32 bytes); a real deployment would load a shared secret. +_REQUEST_STATE_KEY = b"everything-server-fixture-request-state-key" + +mcp = MCPServer( + name="mcp-conformance-test-server", + request_state_security=RequestStateSecurity(keys=[_REQUEST_STATE_KEY]), +) + + +# Tools +@mcp.tool() +def test_simple_text() -> str: + """Tests simple text content response""" + return "This is a simple text response for testing." + + +@mcp.tool() +def test_image_content() -> list[ImageContent]: + """Tests image content response""" + return [ImageContent(type="image", data=TEST_IMAGE_BASE64, mime_type="image/png")] + + +@mcp.tool() +def test_audio_content() -> list[AudioContent]: + """Tests audio content response""" + return [AudioContent(type="audio", data=TEST_AUDIO_BASE64, mime_type="audio/wav")] + + +@mcp.tool() +def test_embedded_resource() -> list[EmbeddedResource]: + """Tests embedded resource content response""" + return [ + EmbeddedResource( + type="resource", + resource=TextResourceContents( + uri="test://embedded-resource", + mime_type="text/plain", + text="This is an embedded resource content.", + ), + ) + ] + + +@mcp.tool() +def test_multiple_content_types() -> list[TextContent | ImageContent | EmbeddedResource]: + """Tests response with multiple content types (text, image, resource)""" + return [ + TextContent(type="text", text="Multiple content types test:"), + ImageContent(type="image", data=TEST_IMAGE_BASE64, mime_type="image/png"), + EmbeddedResource( + type="resource", + resource=TextResourceContents( + uri="test://mixed-content-resource", + mime_type="application/json", + text='{"test": "data", "value": 123}', + ), + ), + ] + + +@mcp.tool() +async def test_tool_with_logging(ctx: Context) -> str: + """Tests tool that emits log messages during execution""" + await ctx.info("Tool execution started") # pyright: ignore[reportDeprecated] + await asyncio.sleep(0.05) + + await ctx.info("Tool processing data") # pyright: ignore[reportDeprecated] + await asyncio.sleep(0.05) + + await ctx.info("Tool execution completed") # pyright: ignore[reportDeprecated] + return "Tool with logging executed successfully" + + +@mcp.tool() +async def test_tool_with_progress(ctx: Context) -> str: + """Tests tool that reports progress notifications""" + await ctx.report_progress(progress=0, total=100, message="Completed step 0 of 100") + await asyncio.sleep(0.05) + + await ctx.report_progress(progress=50, total=100, message="Completed step 50 of 100") + await asyncio.sleep(0.05) + + await ctx.report_progress(progress=100, total=100, message="Completed step 100 of 100") + + # Return progress token as string + progress_token = ( + ctx.request_context.meta.get("progress_token") if ctx.request_context and ctx.request_context.meta else 0 + ) + return str(progress_token) + + +@mcp.tool() +async def test_sampling(prompt: str, ctx: Context) -> str: + """Tests server-initiated sampling (LLM completion request)""" + try: + # Request sampling from client. Without related_request_id the request goes + # to the standalone GET stream and is silently dropped if it is not open yet. + result = await ctx.session.create_message( # pyright: ignore[reportDeprecated] + messages=[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))], + max_tokens=100, + related_request_id=ctx.request_id, + ) + + # Since we're not passing tools param, result.content is single content + if result.content.type == "text": + model_response = result.content.text + else: + model_response = "No response" + + return f"LLM response: {model_response}" + except Exception as e: + return f"Sampling not supported or error: {str(e)}" + + +class UserResponse(BaseModel): + response: str = Field(description="User's response") + + +@mcp.tool() +async def test_elicitation(message: str, ctx: Context) -> str: + """Tests server-initiated elicitation (user input request)""" + try: + # Request user input from client + result = await ctx.elicit(message=message, schema=UserResponse) + + # Type-safe discriminated union narrowing using action field + if result.action == "accept": + content = result.data.model_dump_json() + else: # decline or cancel + content = "{}" + + return f"User response: action={result.action}, content={content}" + except Exception as e: + return f"Elicitation not supported or error: {str(e)}" + + +class SEP1034DefaultsSchema(BaseModel): + """Schema for testing SEP-1034 elicitation with default values for all primitive types""" + + name: str = Field(default="John Doe", description="User name") + age: int = Field(default=30, description="User age") + score: float = Field(default=95.5, description="User score") + status: str = Field( + default="active", + description="User status", + json_schema_extra={"enum": ["active", "inactive", "pending"]}, + ) + verified: bool = Field(default=True, description="Verification status") + + +@mcp.tool() +async def test_elicitation_sep1034_defaults(ctx: Context) -> str: + """Tests elicitation with default values for all primitive types (SEP-1034)""" + try: + # Request user input with defaults for all primitive types + result = await ctx.elicit(message="Please provide user information", schema=SEP1034DefaultsSchema) + + # Type-safe discriminated union narrowing using action field + if result.action == "accept": + content = result.data.model_dump_json() + else: # decline or cancel + content = "{}" + + return f"Elicitation result: action={result.action}, content={content}" + except Exception as e: + return f"Elicitation not supported or error: {str(e)}" + + +class EnumSchemasTestSchema(BaseModel): + """Schema for testing enum schema variations (SEP-1330)""" + + untitledSingle: str = Field( + description="Simple enum without titles", json_schema_extra={"enum": ["active", "inactive", "pending"]} + ) + titledSingle: str = Field( + description="Enum with titled options (oneOf)", + json_schema_extra={ + "oneOf": [ + {"const": "low", "title": "Low Priority"}, + {"const": "medium", "title": "Medium Priority"}, + {"const": "high", "title": "High Priority"}, + ] + }, + ) + untitledMulti: list[str] = Field( + description="Multi-select without titles", + json_schema_extra={"items": {"type": "string", "enum": ["read", "write", "execute"]}}, + ) + titledMulti: list[str] = Field( + description="Multi-select with titled options", + json_schema_extra={ + "items": { + "anyOf": [ + {"const": "feature", "title": "New Feature"}, + {"const": "bug", "title": "Bug Fix"}, + {"const": "docs", "title": "Documentation"}, + ] + } + }, + ) + legacyEnum: str = Field( + description="Legacy enum with enumNames", + json_schema_extra={ + "enum": ["small", "medium", "large"], + "enumNames": ["Small Size", "Medium Size", "Large Size"], + }, + ) + + +@mcp.tool() +async def test_elicitation_sep1330_enums(ctx: Context) -> str: + """Tests elicitation with enum schema variations per SEP-1330""" + try: + result = await ctx.elicit( + message="Please select values using different enum schema types", schema=EnumSchemasTestSchema + ) + + if result.action == "accept": + content = result.data.model_dump_json() + else: + content = "{}" + + return f"Elicitation completed: action={result.action}, content={content}" + except Exception as e: + return f"Elicitation not supported or error: {str(e)}" + + +@mcp.tool() +def test_error_handling() -> str: + """Tests error response handling""" + raise RuntimeError("This tool intentionally returns an error for testing") + + +@mcp.tool() +def test_x_mcp_header( + region: Annotated[ + str, + Field( + description="Mirrored into the Mcp-Param-Region header", + json_schema_extra={"x-mcp-header": "Region"}, + ), + ] = "", +) -> str: + """Tests SEP-2243 Mcp-Param-* server-side validation. + + Arms the http-custom-header-server-validation conformance scenario, which + skips when no tool with an `x-mcp-header` annotation is found. + """ + return f"region={region}" + + +@mcp.tool() +async def test_missing_capability(ctx: Context) -> str: + """Tests that a handler-raised MISSING_REQUIRED_CLIENT_CAPABILITY surfaces as a top-level JSON-RPC error. + + Requires the client to declare the ``sampling`` capability. When absent, raises + `MCPError` (which the tool dispatch re-raises rather than wrapping in + ``CallToolResult.isError``) so the conformance harness observes a protocol-level + error response with ``data.requiredCapabilities``. + """ + client_params = ctx.session.client_params + sampling_declared = client_params is not None and client_params.capabilities.sampling is not None + if not sampling_declared: + raise MCPError( + code=MISSING_REQUIRED_CLIENT_CAPABILITY, + message="This tool requires the client 'sampling' capability", + data={"requiredCapabilities": ["sampling"]}, + ) + return "Client declared sampling capability; proceeding." + + +# SEP-2322 InputRequiredResult fixtures (multi-round-trip / ephemeral workflow) + +NAME_SCHEMA = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]} + + +def _name_elicitation(message: str = "What is your name?") -> ElicitRequest: + return ElicitRequest(params=ElicitRequestFormParams(message=message, requested_schema=NAME_SCHEMA)) + + +@mcp.tool() +async def test_input_required_result_elicitation(ctx: Context) -> str | InputRequiredResult: + """Tests InputRequiredResult with a single elicitation request""" + responses = ctx.input_responses + if responses and "user_name" in responses: + answer = responses["user_name"] + name = answer.content.get("name", "stranger") if isinstance(answer, ElicitResult) and answer.content else "?" + return f"Hello, {name}!" + return InputRequiredResult(input_requests={"user_name": _name_elicitation()}) + + +@mcp.tool() +async def test_input_required_result_sampling(ctx: Context) -> str | InputRequiredResult: + """Tests InputRequiredResult with a single sampling request""" + responses = ctx.input_responses + if responses and "capital_question" in responses: + answer = responses["capital_question"] + text = answer.content.text if isinstance(answer, CreateMessageResult) and answer.content.type == "text" else "?" + return f"Model said: {text}" + return InputRequiredResult( + input_requests={ + "capital_question": CreateMessageRequest( + params=CreateMessageRequestParams( + messages=[ + SamplingMessage( + role="user", content=TextContent(type="text", text="What is the capital of France?") + ) + ], + max_tokens=100, + ) + ) + } + ) + + +@mcp.tool() +async def test_input_required_result_list_roots(ctx: Context) -> str | InputRequiredResult: + """Tests InputRequiredResult with a single roots/list request""" + responses = ctx.input_responses + if responses and "client_roots" in responses: + answer = responses["client_roots"] + count = len(answer.roots) if isinstance(answer, ListRootsResult) else 0 + return f"Client exposed {count} root(s)." + return InputRequiredResult(input_requests={"client_roots": ListRootsRequest()}) + + +@mcp.tool() +async def test_input_required_result_request_state(ctx: Context) -> str | InputRequiredResult: + """Tests requestState round-tripping in the InputRequiredResult flow""" + responses = ctx.input_responses + if responses and "confirm" in responses and ctx.request_state == "request-state-nonce": + return "state-ok: confirmation received" + confirm = ElicitRequest( + params=ElicitRequestFormParams( + message="Please confirm", + requested_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}, "required": ["ok"]}, + ) + ) + return InputRequiredResult(input_requests={"confirm": confirm}, request_state="request-state-nonce") + + +@mcp.tool() +async def test_input_required_result_multiple_inputs(ctx: Context) -> str | InputRequiredResult: + """Tests InputRequiredResult carrying elicitation, sampling and roots requests together""" + responses = ctx.input_responses + if responses and {"user_name", "greeting", "client_roots"} <= responses.keys(): + return "All inputs received." + return InputRequiredResult( + input_requests={ + "user_name": _name_elicitation(), + "greeting": CreateMessageRequest( + params=CreateMessageRequestParams( + messages=[ + SamplingMessage(role="user", content=TextContent(type="text", text="Generate a greeting")) + ], + max_tokens=50, + ) + ), + "client_roots": ListRootsRequest(), + }, + request_state="multiple-inputs", + ) + + +@mcp.tool() +async def test_input_required_result_multi_round(ctx: Context) -> str | InputRequiredResult: + """Tests a three-round InputRequiredResult flow with evolving requestState""" + state = json.loads(ctx.request_state) if ctx.request_state else {"round": 0} + responses = ctx.input_responses or {} + + if state["round"] == 0: + return InputRequiredResult( + input_requests={"step1": _name_elicitation("Step 1: What is your name?")}, + request_state=json.dumps({"round": 1}), + ) + + if state["round"] == 1 and "step1" in responses: + step1 = responses["step1"] + name = step1.content.get("name") if isinstance(step1, ElicitResult) and step1.content else None + color_schema = {"type": "object", "properties": {"color": {"type": "string"}}, "required": ["color"]} + return InputRequiredResult( + input_requests={ + "step2": ElicitRequest( + params=ElicitRequestFormParams( + message="Step 2: What is your favorite color?", requested_schema=color_schema + ) + ) + }, + request_state=json.dumps({"round": 2, "name": name}), + ) + + if state["round"] == 2 and "step2" in responses: + step2 = responses["step2"] + color = step2.content.get("color") if isinstance(step2, ElicitResult) and step2.content else None + return f"{state.get('name')} likes {color}." + + # Missing or out-of-order response: re-request from the start. + return InputRequiredResult( + input_requests={"step1": _name_elicitation("Step 1: What is your name?")}, + request_state=json.dumps({"round": 1}), + ) + + +@mcp.tool() +async def test_input_required_result_tampered_state(ctx: Context) -> str | InputRequiredResult: + """Tests that the server rejects a tampered requestState echo. + + The handler stays plaintext; tamper rejection happens in the SDK's request-state boundary. + """ + if ctx.request_state is None: + confirm = ElicitRequest( + params=ElicitRequestFormParams( + message="Please confirm", + requested_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}, "required": ["ok"]}, + ) + ) + return InputRequiredResult(input_requests={"confirm": confirm}, request_state="round-1") + return f"state-ok: {ctx.request_state}" + + +@mcp.tool() +async def test_input_required_result_capabilities(ctx: Context) -> InputRequiredResult: + """Tests that inputRequests only include methods the client declared support for""" + caps = ctx.client_capabilities + requests: dict[str, InputRequest] = {} + if caps is None or caps.sampling is not None: + requests["sample"] = CreateMessageRequest( + params=CreateMessageRequestParams( + messages=[SamplingMessage(role="user", content=TextContent(type="text", text="Say hello"))], + max_tokens=50, + ) + ) + if caps is None or caps.elicitation is not None: + requests["ask"] = _name_elicitation() + return InputRequiredResult(input_requests=requests, request_state="capability-gated") + + +# SEP-1613 / SEP-2106 JSON Schema 2020-12 fixture: a tool whose inputSchema carries +# the full set of 2020-12 keywords the conformance scenario asserts on. + +JSON_SCHEMA_2020_12_INPUT_SCHEMA: dict[str, Any] = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "$defs": { + "address": { + "$anchor": "addressDef", + "type": "object", + "properties": {"street": {"type": "string"}, "city": {"type": "string"}}, + } + }, + "properties": { + "name": {"type": "string"}, + "address": {"$ref": "#/$defs/address"}, + "contactMethod": {"type": "string", "enum": ["phone", "email"]}, + "phone": {"type": "string"}, + "email": {"type": "string"}, + }, + "allOf": [{"anyOf": [{"required": ["phone"]}, {"required": ["email"]}]}], + "if": {"properties": {"contactMethod": {"const": "phone"}}, "required": ["contactMethod"]}, + "then": {"required": ["phone"]}, + "else": {"required": ["email"]}, + "additionalProperties": False, +} + + +@mcp.tool(name="json_schema_2020_12_tool") +def json_schema_2020_12_tool() -> str: + """Tests JSON Schema 2020-12 keyword preservation in tools/list (inputSchema installed below).""" + return "json_schema_2020_12_tool" + + +# TODO(felix): replace with a public input_schema= override once MCPServer.tool() grows one. +mcp._tool_manager._tools["json_schema_2020_12_tool"].parameters = ( # pyright: ignore[reportPrivateUsage] + JSON_SCHEMA_2020_12_INPUT_SCHEMA +) + + +@mcp.tool() +async def test_reconnection(ctx: Context) -> str: + """Tests SSE polling by closing stream mid-call (SEP-1699)""" + await ctx.info("Before disconnect") # pyright: ignore[reportDeprecated] + + await ctx.close_sse_stream() + + await asyncio.sleep(0.2) # Wait for client to reconnect + + await ctx.info("After reconnect") # pyright: ignore[reportDeprecated] + return "Reconnection test completed" + + +def _dynamic_tool() -> str: + """A tool registered and removed by test_trigger_tool_change.""" + return "dynamic" + + +def _dynamic_prompt() -> str: + """A prompt registered and removed by test_trigger_prompt_change.""" + return "dynamic" + + +@mcp.tool() +async def test_trigger_tool_change(ctx: Context) -> str: + """Mutates the tool list and announces it to subscriptions/listen streams (SEP-2575)""" + mcp.add_tool(_dynamic_tool, name="test_dynamic_tool") + mcp.remove_tool("test_dynamic_tool") + await ctx.notify_tools_changed() + return "tool list changed" + + +@mcp.tool() +async def test_trigger_prompt_change(ctx: Context) -> str: + """Mutates the prompt list and announces it to subscriptions/listen streams (SEP-2575)""" + mcp.add_prompt(Prompt.from_function(_dynamic_prompt, name="test_dynamic_prompt", description="dynamic")) + mcp.remove_prompt("test_dynamic_prompt") + await ctx.notify_prompts_changed() + return "prompt list changed" + + +# Resources +@mcp.resource("test://static-text") +def static_text_resource() -> str: + """A static text resource for testing""" + return "This is the content of the static text resource." + + +@mcp.resource("test://static-binary") +def static_binary_resource() -> bytes: + """A static binary resource (image) for testing""" + return base64.b64decode(TEST_IMAGE_BASE64) + + +@mcp.resource("test://template/{id}/data") +def template_resource(id: str) -> str: + """A resource template with parameter substitution""" + return json.dumps({"id": id, "templateTest": True, "data": f"Data for ID: {id}"}) + + +@mcp.resource("test://watched-resource") +def watched_resource() -> str: + """A resource that can be subscribed to for updates""" + return watched_resource_content + + +# Prompts +@mcp.prompt() +def test_simple_prompt() -> list[UserMessage]: + """A simple prompt without arguments""" + return [UserMessage(role="user", content=TextContent(type="text", text="This is a simple prompt for testing."))] + + +@mcp.prompt() +def test_prompt_with_arguments(arg1: str, arg2: str) -> list[UserMessage]: + """A prompt with required arguments""" + return [ + UserMessage( + role="user", content=TextContent(type="text", text=f"Prompt with arguments: arg1='{arg1}', arg2='{arg2}'") + ) + ] + + +@mcp.prompt() +def test_prompt_with_embedded_resource(resourceUri: str) -> list[UserMessage]: + """A prompt that includes an embedded resource""" + return [ + UserMessage( + role="user", + content=EmbeddedResource( + type="resource", + resource=TextResourceContents( + uri=resourceUri, + mime_type="text/plain", + text="Embedded resource content for testing.", + ), + ), + ), + UserMessage(role="user", content=TextContent(type="text", text="Please process the embedded resource above.")), + ] + + +@mcp.prompt() +def test_prompt_with_image() -> list[UserMessage]: + """A prompt that includes image content""" + return [ + UserMessage(role="user", content=ImageContent(type="image", data=TEST_IMAGE_BASE64, mime_type="image/png")), + UserMessage(role="user", content=TextContent(type="text", text="Please analyze the image above.")), + ] + + +@mcp.prompt() +async def test_input_required_result_prompt(ctx: Context) -> list[UserMessage] | InputRequiredResult: + """Tests InputRequiredResult from prompts/get (SEP-2322 non-tool request)""" + responses = ctx.input_responses + if responses and "user_context" in responses: + answer = responses["user_context"] + text = answer.content.get("context", "?") if isinstance(answer, ElicitResult) and answer.content else "?" + return [UserMessage(role="user", content=TextContent(type="text", text=f"Use the following context: {text}"))] + return InputRequiredResult( + input_requests={ + "user_context": ElicitRequest( + params=ElicitRequestFormParams( + message="What context should the prompt use?", + requested_schema={ + "type": "object", + "properties": {"context": {"type": "string"}}, + "required": ["context"], + }, + ) + ) + } + ) + + +# Custom request handlers +# TODO(felix): Add public APIs to MCPServer for subscribe_resource, unsubscribe_resource, +# and set_logging_level to avoid accessing protected _lowlevel_server attribute. +async def handle_set_logging_level(ctx: ServerRequestContext, params: SetLevelRequestParams) -> EmptyResult: + """Handle logging level changes""" + logger.info(f"Log level set to: {params.level}") + return EmptyResult() + + +async def handle_subscribe(ctx: ServerRequestContext, params: SubscribeRequestParams) -> EmptyResult: + """Handle resource subscription""" + resource_subscriptions.add(str(params.uri)) + logger.info(f"Subscribed to resource: {params.uri}") + return EmptyResult() + + +async def handle_unsubscribe(ctx: ServerRequestContext, params: UnsubscribeRequestParams) -> EmptyResult: + """Handle resource unsubscription""" + resource_subscriptions.discard(str(params.uri)) + logger.info(f"Unsubscribed from resource: {params.uri}") + return EmptyResult() + + +mcp._lowlevel_server.add_request_handler( # pyright: ignore[reportPrivateUsage] + "logging/setLevel", SetLevelRequestParams, handle_set_logging_level +) +mcp._lowlevel_server.add_request_handler( # pyright: ignore[reportPrivateUsage] + "resources/subscribe", SubscribeRequestParams, handle_subscribe +) +mcp._lowlevel_server.add_request_handler( # pyright: ignore[reportPrivateUsage] + "resources/unsubscribe", UnsubscribeRequestParams, handle_unsubscribe +) + + +@mcp.completion() +async def _handle_completion( + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, +) -> Completion: + """Handle completion requests""" + # Basic completion support - returns empty array for conformance + # Real implementations would provide contextual suggestions + return Completion(values=[], total=0, has_more=False) + + +# CLI +@click.command() +@click.option("--port", default=3001, help="Port to listen on for HTTP") +@click.option( + "--log-level", + default="INFO", + help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)", +) +def main(port: int, log_level: str) -> int: + """Run the MCP Everything Server.""" + logging.basicConfig( + level=getattr(logging, log_level.upper()), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + logger.info(f"Starting MCP Everything Server on port {port}") + logger.info(f"Endpoint will be: http://localhost:{port}/mcp") + + mcp.run( + transport="streamable-http", + port=port, + event_store=event_store, + retry_interval=100, # 100ms retry interval for SSE polling + ) + + return 0 + + +if __name__ == "__main__": + main() diff --git a/examples/servers/everything-server/pyproject.toml b/examples/servers/everything-server/pyproject.toml new file mode 100644 index 0000000..f68a9d2 --- /dev/null +++ b/examples/servers/everything-server/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "mcp-everything-server" +version = "0.1.0" +description = "Comprehensive MCP server implementing all protocol features for conformance testing" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "automation", "conformance", "testing"] +license = { text = "MIT" } +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"] + +[project.scripts] +mcp-everything-server = "mcp_everything_server.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_everything_server"] + +[tool.pyright] +include = ["mcp_everything_server"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-auth/README.md b/examples/servers/simple-auth/README.md new file mode 100644 index 0000000..d4a10c4 --- /dev/null +++ b/examples/servers/simple-auth/README.md @@ -0,0 +1,135 @@ +# MCP OAuth Authentication Demo + +This example demonstrates OAuth 2.0 authentication with the Model Context Protocol using **separate Authorization Server (AS) and Resource Server (RS)** to comply with the new RFC 9728 specification. + +--- + +## Running the Servers + +### Step 1: Start Authorization Server + +```bash +# Navigate to the simple-auth directory +cd examples/servers/simple-auth + +# Start Authorization Server on port 9000 +uv run mcp-simple-auth-as --port=9000 +``` + +**What it provides:** + +- OAuth 2.0 flows (registration, authorization, token exchange) +- Simple credential-based authentication (no external provider needed) +- Token introspection endpoint for Resource Servers (`/introspect`) + +--- + +### Step 2: Start Resource Server (MCP Server) + +```bash +# In another terminal, navigate to the simple-auth directory +cd examples/servers/simple-auth + +# Start Resource Server on port 8001, connected to Authorization Server +uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http + +# With RFC 8707 strict resource validation (recommended for production) +uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http --oauth-strict + +``` + +### Step 3: Test with Client + +```bash +cd examples/clients/simple-auth-client +# Start client with streamable HTTP +MCP_SERVER_PORT=8001 MCP_TRANSPORT_TYPE=streamable-http uv run mcp-simple-auth-client +``` + +## How It Works + +### RFC 9728 Discovery + +**Client → Resource Server:** + +```bash +curl http://localhost:8001/.well-known/oauth-protected-resource +``` + +```json +{ + "resource": "http://localhost:8001", + "authorization_servers": ["http://localhost:9000"] +} +``` + +**Client → Authorization Server:** + +```bash +curl http://localhost:9000/.well-known/oauth-authorization-server +``` + +```json +{ + "issuer": "http://localhost:9000", + "authorization_endpoint": "http://localhost:9000/authorize", + "token_endpoint": "http://localhost:9000/token" +} +``` + +## Legacy MCP Server as Authorization Server (Backwards Compatibility) + +For backwards compatibility with older MCP implementations, a legacy server is provided that acts as an Authorization Server (following the old spec where MCP servers could optionally provide OAuth): + +### Running the Legacy Server + +```bash +# Start legacy server on port 8000 (the default) +cd examples/servers/simple-auth +uv run mcp-simple-auth-legacy --port=8000 --transport=streamable-http +``` + +**Differences from the new architecture:** + +- **MCP server acts as AS:** The MCP server itself provides OAuth endpoints (old spec behavior) +- **No separate RS:** The server handles both authentication and MCP tools +- **Local token validation:** Tokens are validated internally without introspection +- **No RFC 9728 support:** Does not provide `/.well-known/oauth-protected-resource` +- **Direct OAuth discovery:** OAuth metadata is at the MCP server's URL + +### Testing with Legacy Server + +```bash +# Test with client (will automatically fall back to legacy discovery) +cd examples/clients/simple-auth-client +MCP_SERVER_PORT=8000 MCP_TRANSPORT_TYPE=streamable-http uv run mcp-simple-auth-client +``` + +The client will: + +1. Try RFC 9728 discovery at `/.well-known/oauth-protected-resource` (404 on legacy server) +2. Fall back to direct OAuth discovery at `/.well-known/oauth-authorization-server` +3. Complete authentication with the MCP server acting as its own AS + +This ensures existing MCP servers (which could optionally act as Authorization Servers under the old spec) continue to work while the ecosystem transitions to the new architecture where MCP servers are Resource Servers only. + +## Manual Testing + +### Test Discovery + +```bash +# Test Resource Server discovery endpoint (new architecture) +curl -v http://localhost:8001/.well-known/oauth-protected-resource + +# Test Authorization Server metadata +curl -v http://localhost:9000/.well-known/oauth-authorization-server +``` + +### Test Token Introspection + +```bash +# After getting a token through OAuth flow: +curl -X POST http://localhost:9000/introspect \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "token=your_access_token" +``` diff --git a/examples/servers/simple-auth/mcp_simple_auth/__init__.py b/examples/servers/simple-auth/mcp_simple_auth/__init__.py new file mode 100644 index 0000000..3e12b31 --- /dev/null +++ b/examples/servers/simple-auth/mcp_simple_auth/__init__.py @@ -0,0 +1 @@ +"""Simple MCP server with GitHub OAuth authentication.""" diff --git a/examples/servers/simple-auth/mcp_simple_auth/__main__.py b/examples/servers/simple-auth/mcp_simple_auth/__main__.py new file mode 100644 index 0000000..2365ff5 --- /dev/null +++ b/examples/servers/simple-auth/mcp_simple_auth/__main__.py @@ -0,0 +1,7 @@ +"""Main entry point for simple MCP server with GitHub OAuth authentication.""" + +import sys + +from mcp_simple_auth.server import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-auth/mcp_simple_auth/auth_server.py b/examples/servers/simple-auth/mcp_simple_auth/auth_server.py new file mode 100644 index 0000000..26c87c5 --- /dev/null +++ b/examples/servers/simple-auth/mcp_simple_auth/auth_server.py @@ -0,0 +1,185 @@ +"""Authorization Server for MCP Split Demo. + +This server handles OAuth flows, client registration, and token issuance. +Can be replaced with enterprise authorization servers like Auth0, Entra ID, etc. + +NOTE: this is a simplified example for demonstration purposes. +This is not a production-ready implementation. + +""" + +import asyncio +import logging +import time + +import click +from pydantic import AnyHttpUrl, BaseModel +from starlette.applications import Starlette +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Route +from uvicorn import Config, Server + +from mcp.server.auth.routes import cors_middleware, create_auth_routes +from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions + +from .simple_auth_provider import SimpleAuthSettings, SimpleOAuthProvider + +logger = logging.getLogger(__name__) + + +class AuthServerSettings(BaseModel): + """Settings for the Authorization Server.""" + + # Server settings + host: str = "localhost" + port: int = 9000 + server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") + auth_callback_path: str = "http://localhost:9000/login/callback" + + +class SimpleAuthProvider(SimpleOAuthProvider): + """Authorization Server provider with simple demo authentication. + + This provider: + 1. Issues MCP tokens after simple credential authentication + 2. Stores token state for introspection by Resource Servers + """ + + def __init__(self, auth_settings: SimpleAuthSettings, auth_callback_path: str, server_url: str): + super().__init__(auth_settings, auth_callback_path, server_url) + + +def create_authorization_server(server_settings: AuthServerSettings, auth_settings: SimpleAuthSettings) -> Starlette: + """Create the Authorization Server application.""" + oauth_provider = SimpleAuthProvider( + auth_settings, server_settings.auth_callback_path, str(server_settings.server_url) + ) + + mcp_auth_settings = AuthSettings( + issuer_url=server_settings.server_url, + client_registration_options=ClientRegistrationOptions( + enabled=True, + valid_scopes=[auth_settings.mcp_scope], + default_scopes=[auth_settings.mcp_scope], + ), + required_scopes=[auth_settings.mcp_scope], + resource_server_url=None, + ) + + # Create OAuth routes + routes = create_auth_routes( + provider=oauth_provider, + issuer_url=mcp_auth_settings.issuer_url, + service_documentation_url=mcp_auth_settings.service_documentation_url, + client_registration_options=mcp_auth_settings.client_registration_options, + revocation_options=mcp_auth_settings.revocation_options, + ) + + # Add login page route (GET) + async def login_page_handler(request: Request) -> Response: + """Show login form.""" + state = request.query_params.get("state") + if not state: + raise HTTPException(400, "Missing state parameter") + return await oauth_provider.get_login_page(state) + + routes.append(Route("/login", endpoint=login_page_handler, methods=["GET"])) + + # Add login callback route (POST) + async def login_callback_handler(request: Request) -> Response: + """Handle simple authentication callback.""" + return await oauth_provider.handle_login_callback(request) + + routes.append(Route("/login/callback", endpoint=login_callback_handler, methods=["POST"])) + + # Add token introspection endpoint (RFC 7662) for Resource Servers + async def introspect_handler(request: Request) -> Response: + """Token introspection endpoint for Resource Servers. + + Resource Servers call this endpoint to validate tokens without + needing direct access to token storage. + """ + form = await request.form() + token = form.get("token") + if not token or not isinstance(token, str): + return JSONResponse({"active": False}, status_code=400) + + # Look up token in provider + access_token = await oauth_provider.load_access_token(token) + if not access_token: + return JSONResponse({"active": False}) + + return JSONResponse( + { + "active": True, + "client_id": access_token.client_id, + "scope": " ".join(access_token.scopes), + "exp": access_token.expires_at, + "iat": int(time.time()), + "token_type": "Bearer", + "aud": access_token.resource, # RFC 8707 audience claim + "sub": access_token.subject, # RFC 7662 subject + "iss": str(server_settings.server_url), + } + ) + + routes.append( + Route( + "/introspect", + endpoint=cors_middleware(introspect_handler, ["POST", "OPTIONS"]), + methods=["POST", "OPTIONS"], + ) + ) + + return Starlette(routes=routes) + + +async def run_server(server_settings: AuthServerSettings, auth_settings: SimpleAuthSettings): + """Run the Authorization Server.""" + auth_server = create_authorization_server(server_settings, auth_settings) + + config = Config( + auth_server, + host=server_settings.host, + port=server_settings.port, + log_level="info", + ) + server = Server(config) + + logger.info(f"🚀 MCP Authorization Server running on {server_settings.server_url}") + + await server.serve() + + +@click.command() +@click.option("--port", default=9000, help="Port to listen on") +def main(port: int) -> int: + """Run the MCP Authorization Server. + + This server handles OAuth flows and can be used by multiple Resource Servers. + + Uses simple hardcoded credentials for demo purposes. + """ + logging.basicConfig(level=logging.INFO) + + # Load simple auth settings + auth_settings = SimpleAuthSettings() + + # Create server settings + host = "localhost" + server_url = f"http://{host}:{port}" + server_settings = AuthServerSettings( + host=host, + port=port, + server_url=AnyHttpUrl(server_url), + auth_callback_path=f"{server_url}/login", + ) + + asyncio.run(run_server(server_settings, auth_settings)) + return 0 + + +if __name__ == "__main__": + main() # type: ignore[call-arg] diff --git a/examples/servers/simple-auth/mcp_simple_auth/legacy_as_server.py b/examples/servers/simple-auth/mcp_simple_auth/legacy_as_server.py new file mode 100644 index 0000000..ab7773b --- /dev/null +++ b/examples/servers/simple-auth/mcp_simple_auth/legacy_as_server.py @@ -0,0 +1,137 @@ +"""Legacy Combined Authorization Server + Resource Server for MCP. + +This server implements the old spec where MCP servers could act as both AS and RS. +Used for backwards compatibility testing with the new split AS/RS architecture. + +NOTE: this is a simplified example for demonstration purposes. +This is not a production-ready implementation. + +""" + +import datetime +import logging +from typing import Any, Literal + +import click +from pydantic import AnyHttpUrl, BaseModel +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import Response + +from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions +from mcp.server.mcpserver.server import MCPServer + +from .simple_auth_provider import SimpleAuthSettings, SimpleOAuthProvider + +logger = logging.getLogger(__name__) + + +class ServerSettings(BaseModel): + """Settings for the simple auth MCP server.""" + + # Server settings + host: str = "localhost" + port: int = 8000 + server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8000") + auth_callback_path: str = "http://localhost:8000/login/callback" + + +class LegacySimpleOAuthProvider(SimpleOAuthProvider): + """Simple OAuth provider for legacy MCP server.""" + + def __init__(self, auth_settings: SimpleAuthSettings, auth_callback_path: str, server_url: str): + super().__init__(auth_settings, auth_callback_path, server_url) + + +def create_simple_mcp_server(server_settings: ServerSettings, auth_settings: SimpleAuthSettings) -> MCPServer: + """Create a simple MCPServer server with simple authentication.""" + oauth_provider = LegacySimpleOAuthProvider( + auth_settings, server_settings.auth_callback_path, str(server_settings.server_url) + ) + + mcp_auth_settings = AuthSettings( + issuer_url=server_settings.server_url, + client_registration_options=ClientRegistrationOptions( + enabled=True, + valid_scopes=[auth_settings.mcp_scope], + default_scopes=[auth_settings.mcp_scope], + ), + required_scopes=[auth_settings.mcp_scope], + # No resource_server_url parameter in legacy mode + resource_server_url=None, + ) + + app = MCPServer( + name="Simple Auth MCP Server", + instructions="A simple MCP server with simple credential authentication", + auth_server_provider=oauth_provider, + debug=True, + auth=mcp_auth_settings, + ) + # Store server settings for later use in run() + app._server_settings = server_settings # type: ignore[attr-defined] + + @app.custom_route("/login", methods=["GET"]) + async def login_page_handler(request: Request) -> Response: + """Show login form.""" + state = request.query_params.get("state") + if not state: + raise HTTPException(400, "Missing state parameter") + return await oauth_provider.get_login_page(state) + + @app.custom_route("/login/callback", methods=["POST"]) + async def login_callback_handler(request: Request) -> Response: + """Handle simple authentication callback.""" + return await oauth_provider.handle_login_callback(request) + + @app.tool() + async def get_time() -> dict[str, Any]: + """Get the current server time. + + This tool demonstrates that system information can be protected + by OAuth authentication. User must be authenticated to access it. + """ + + now = datetime.datetime.now() + + return { + "current_time": now.isoformat(), + "timezone": "UTC", # Simplified for demo + "timestamp": now.timestamp(), + "formatted": now.strftime("%Y-%m-%d %H:%M:%S"), + } + + return app + + +@click.command() +@click.option("--port", default=8000, help="Port to listen on") +@click.option( + "--transport", + default="streamable-http", + type=click.Choice(["sse", "streamable-http"]), + help="Transport protocol to use ('sse' or 'streamable-http')", +) +def main(port: int, transport: Literal["sse", "streamable-http"]) -> int: + """Run the simple auth MCP server.""" + logging.basicConfig(level=logging.INFO) + + auth_settings = SimpleAuthSettings() + # Create server settings + host = "localhost" + server_url = f"http://{host}:{port}" + server_settings = ServerSettings( + host=host, + port=port, + server_url=AnyHttpUrl(server_url), + auth_callback_path=f"{server_url}/login", + ) + + mcp_server = create_simple_mcp_server(server_settings, auth_settings) + logger.info(f"🚀 MCP Legacy Server running on {server_url}") + mcp_server.run(transport=transport, host=host, port=port) + return 0 + + +if __name__ == "__main__": + main() # type: ignore[call-arg] diff --git a/examples/servers/simple-auth/mcp_simple_auth/py.typed b/examples/servers/simple-auth/mcp_simple_auth/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/examples/servers/simple-auth/mcp_simple_auth/server.py b/examples/servers/simple-auth/mcp_simple_auth/server.py new file mode 100644 index 0000000..0320871 --- /dev/null +++ b/examples/servers/simple-auth/mcp_simple_auth/server.py @@ -0,0 +1,161 @@ +"""MCP Resource Server with Token Introspection. + +This server validates tokens via Authorization Server introspection and serves MCP resources. +Demonstrates RFC 9728 Protected Resource Metadata for AS/RS separation. + +NOTE: this is a simplified example for demonstration purposes. +This is not a production-ready implementation. +""" + +import datetime +import logging +from typing import Any, Literal + +import click +from pydantic import AnyHttpUrl +from pydantic_settings import BaseSettings, SettingsConfigDict + +from mcp.server.auth.settings import AuthSettings +from mcp.server.mcpserver.server import MCPServer + +from .token_verifier import IntrospectionTokenVerifier + +logger = logging.getLogger(__name__) + + +class ResourceServerSettings(BaseSettings): + """Settings for the MCP Resource Server.""" + + model_config = SettingsConfigDict(env_prefix="MCP_RESOURCE_") + + # Server settings + host: str = "localhost" + port: int = 8001 + server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8001/mcp") + + # Authorization Server settings + auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") + auth_server_introspection_endpoint: str = "http://localhost:9000/introspect" + # No user endpoint needed - we get user data from token introspection + + # MCP settings + mcp_scope: str = "user" + + # RFC 8707 resource validation + oauth_strict: bool = False + + +def create_resource_server(settings: ResourceServerSettings) -> MCPServer: + """Create MCP Resource Server with token introspection. + + This server: + 1. Provides protected resource metadata (RFC 9728) + 2. Validates tokens via Authorization Server introspection + 3. Serves MCP tools and resources + """ + # Create token verifier for introspection with RFC 8707 resource validation + token_verifier = IntrospectionTokenVerifier( + introspection_endpoint=settings.auth_server_introspection_endpoint, + server_url=str(settings.server_url), + validate_resource=settings.oauth_strict, # Only validate when --oauth-strict is set + ) + + # Create MCPServer server as a Resource Server + app = MCPServer( + name="MCP Resource Server", + instructions="Resource Server that validates tokens via Authorization Server introspection", + debug=True, + # Auth configuration for RS mode + token_verifier=token_verifier, + auth=AuthSettings( + issuer_url=settings.auth_server_url, + required_scopes=[settings.mcp_scope], + resource_server_url=settings.server_url, + ), + ) + # Store settings for later use in run() + app._resource_server_settings = settings # type: ignore[attr-defined] + + @app.tool() + async def get_time() -> dict[str, Any]: + """Get the current server time. + + This tool demonstrates that system information can be protected + by OAuth authentication. User must be authenticated to access it. + """ + + now = datetime.datetime.now() + + return { + "current_time": now.isoformat(), + "timezone": "UTC", # Simplified for demo + "timestamp": now.timestamp(), + "formatted": now.strftime("%Y-%m-%d %H:%M:%S"), + } + + return app + + +@click.command() +@click.option("--port", default=8001, help="Port to listen on") +@click.option("--auth-server", default="http://localhost:9000", help="Authorization Server URL") +@click.option( + "--transport", + default="streamable-http", + type=click.Choice(["sse", "streamable-http"]), + help="Transport protocol to use ('sse' or 'streamable-http')", +) +@click.option( + "--oauth-strict", + is_flag=True, + help="Enable RFC 8707 resource validation", +) +def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http"], oauth_strict: bool) -> int: + """Run the MCP Resource Server. + + This server: + - Provides RFC 9728 Protected Resource Metadata + - Validates tokens via Authorization Server introspection + - Serves MCP tools requiring authentication + + Must be used with a running Authorization Server. + """ + logging.basicConfig(level=logging.INFO) + + try: + # Parse auth server URL + auth_server_url = AnyHttpUrl(auth_server) + + # Create settings + host = "localhost" + server_url = f"http://{host}:{port}/mcp" + settings = ResourceServerSettings( + host=host, + port=port, + server_url=AnyHttpUrl(server_url), + auth_server_url=auth_server_url, + auth_server_introspection_endpoint=f"{auth_server}/introspect", + oauth_strict=oauth_strict, + ) + except ValueError as e: + logger.error(f"Configuration error: {e}") + logger.error("Make sure to provide a valid Authorization Server URL") + return 1 + + try: + mcp_server = create_resource_server(settings) + + logger.info(f"🚀 MCP Resource Server running on {settings.server_url}") + logger.info(f"🔑 Using Authorization Server: {settings.auth_server_url}") + + # Run the server - this should block and keep running + mcp_server.run(transport=transport, host=host, port=port) + logger.info("Server stopped") + return 0 + except Exception: + logger.exception("Server error") + return 1 + + +if __name__ == "__main__": + main() # type: ignore[call-arg] diff --git a/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py b/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py new file mode 100644 index 0000000..48eb9a8 --- /dev/null +++ b/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py @@ -0,0 +1,272 @@ +"""Simple OAuth provider for MCP servers. + +This module contains a basic OAuth implementation using hardcoded user credentials +for demonstration purposes. No external authentication provider is required. + +NOTE: this is a simplified example for demonstration purposes. +This is not a production-ready implementation. + +""" + +import secrets +import time +from typing import Any + +from pydantic import AnyHttpUrl +from pydantic_settings import BaseSettings, SettingsConfigDict +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import HTMLResponse, RedirectResponse, Response + +from mcp.server.auth.provider import ( + AccessToken, + AuthorizationCode, + AuthorizationParams, + OAuthAuthorizationServerProvider, + RefreshToken, + construct_redirect_uri, +) +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken + + +class SimpleAuthSettings(BaseSettings): + """Simple OAuth settings for demo purposes.""" + + model_config = SettingsConfigDict(env_prefix="MCP_") + + # Demo user credentials + demo_username: str = "demo_user" + demo_password: str = "demo_password" + + # MCP OAuth scope + mcp_scope: str = "user" + + +class SimpleOAuthProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]): + """Simple OAuth provider for demo purposes. + + This provider handles the OAuth flow by: + 1. Providing a simple login form for demo credentials + 2. Issuing MCP tokens after successful authentication + 3. Maintaining token state for introspection + """ + + def __init__(self, settings: SimpleAuthSettings, auth_callback_url: str, server_url: str): + self.settings = settings + self.auth_callback_url = auth_callback_url + self.server_url = server_url + self.clients: dict[str, OAuthClientInformationFull] = {} + self.auth_codes: dict[str, AuthorizationCode] = {} + self.tokens: dict[str, AccessToken] = {} + self.state_mapping: dict[str, dict[str, str | None]] = {} + # Store authenticated user information + self.user_data: dict[str, dict[str, Any]] = {} + + async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: + """Get OAuth client information.""" + return self.clients.get(client_id) + + async def register_client(self, client_info: OAuthClientInformationFull): + """Register a new OAuth client.""" + if not client_info.client_id: + raise ValueError("No client_id provided") + self.clients[client_info.client_id] = client_info + + async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: + """Generate an authorization URL for simple login flow.""" + state = params.state or secrets.token_hex(16) + + # Store state mapping for callback + self.state_mapping[state] = { + "redirect_uri": str(params.redirect_uri), + "code_challenge": params.code_challenge, + "redirect_uri_provided_explicitly": str(params.redirect_uri_provided_explicitly), + "client_id": client.client_id, + "resource": params.resource, # RFC 8707 + } + + # Build simple login URL that points to login page + auth_url = f"{self.auth_callback_url}?state={state}&client_id={client.client_id}" + + return auth_url + + async def get_login_page(self, state: str) -> HTMLResponse: + """Generate login page HTML for the given state.""" + if not state: + raise HTTPException(400, "Missing state parameter") + + # Create simple login form HTML + html_content = f""" + + + + MCP Demo Authentication + + + +

MCP Demo Authentication

+

This is a simplified authentication demo. Use the demo credentials below:

+

Username: demo_user
+ Password: demo_password

+ +
+ +
+ + +
+
+ + +
+ +
+ + + """ + + return HTMLResponse(content=html_content) + + async def handle_login_callback(self, request: Request) -> Response: + """Handle login form submission callback.""" + form = await request.form() + username = form.get("username") + password = form.get("password") + state = form.get("state") + + if not username or not password or not state: + raise HTTPException(400, "Missing username, password, or state parameter") + + # Ensure we have strings, not UploadFile objects + if not isinstance(username, str) or not isinstance(password, str) or not isinstance(state, str): + raise HTTPException(400, "Invalid parameter types") + + redirect_uri = await self.handle_simple_callback(username, password, state) + return RedirectResponse(url=redirect_uri, status_code=302) + + async def handle_simple_callback(self, username: str, password: str, state: str) -> str: + """Handle simple authentication callback and return redirect URI.""" + state_data = self.state_mapping.get(state) + if not state_data: + raise HTTPException(400, "Invalid state parameter") + + redirect_uri = state_data["redirect_uri"] + code_challenge = state_data["code_challenge"] + redirect_uri_provided_explicitly = state_data["redirect_uri_provided_explicitly"] == "True" + client_id = state_data["client_id"] + resource = state_data.get("resource") # RFC 8707 + + # These are required values from our own state mapping + assert redirect_uri is not None + assert code_challenge is not None + assert client_id is not None + + # Validate demo credentials + if username != self.settings.demo_username or password != self.settings.demo_password: + raise HTTPException(401, "Invalid credentials") + + # Create MCP authorization code + new_code = f"mcp_{secrets.token_hex(16)}" + auth_code = AuthorizationCode( + code=new_code, + client_id=client_id, + redirect_uri=AnyHttpUrl(redirect_uri), + redirect_uri_provided_explicitly=redirect_uri_provided_explicitly, + expires_at=time.time() + 300, + scopes=[self.settings.mcp_scope], + code_challenge=code_challenge, + resource=resource, # RFC 8707 + subject=username, + ) + self.auth_codes[new_code] = auth_code + + # Store user data + self.user_data[username] = { + "username": username, + "user_id": f"user_{secrets.token_hex(8)}", + "authenticated_at": time.time(), + } + + del self.state_mapping[state] + return construct_redirect_uri(redirect_uri, code=new_code, state=state) + + async def load_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: str + ) -> AuthorizationCode | None: + """Load an authorization code.""" + return self.auth_codes.get(authorization_code) + + async def exchange_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode + ) -> OAuthToken: + """Exchange authorization code for tokens.""" + if authorization_code.code not in self.auth_codes: + raise ValueError("Invalid authorization code") + if not client.client_id: + raise ValueError("No client_id provided") + + # Generate MCP access token + mcp_token = f"mcp_{secrets.token_hex(32)}" + + # Store MCP token + self.tokens[mcp_token] = AccessToken( + token=mcp_token, + client_id=client.client_id, + scopes=authorization_code.scopes, + expires_at=int(time.time()) + 3600, + resource=authorization_code.resource, # RFC 8707 + subject=authorization_code.subject, + ) + + # Store user data mapping for this token + self.user_data[mcp_token] = { + "username": self.settings.demo_username, + "user_id": f"user_{secrets.token_hex(8)}", + "authenticated_at": time.time(), + } + + del self.auth_codes[authorization_code.code] + + return OAuthToken( + access_token=mcp_token, + token_type="Bearer", + expires_in=3600, + scope=" ".join(authorization_code.scopes), + ) + + async def load_access_token(self, token: str) -> AccessToken | None: + """Load and validate an access token.""" + access_token = self.tokens.get(token) + if not access_token: + return None + + # Check if expired + if access_token.expires_at and access_token.expires_at < time.time(): + del self.tokens[token] + return None + + return access_token + + async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None: + """Load a refresh token - not supported in this example.""" + return None + + async def exchange_refresh_token( + self, + client: OAuthClientInformationFull, + refresh_token: RefreshToken, + scopes: list[str], + ) -> OAuthToken: + """Exchange refresh token - not supported in this example.""" + raise NotImplementedError("Refresh tokens not supported") + + # TODO(Marcelo): The type hint is wrong. We need to fix, and test to check if it works. + async def revoke_token(self, token: str, token_type_hint: str | None = None) -> None: # type: ignore + """Revoke a token.""" + if token in self.tokens: + del self.tokens[token] diff --git a/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py b/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py new file mode 100644 index 0000000..641095a --- /dev/null +++ b/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py @@ -0,0 +1,108 @@ +"""Example token verifier implementation using OAuth 2.0 Token Introspection (RFC 7662).""" + +import logging +from typing import Any + +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url + +logger = logging.getLogger(__name__) + + +class IntrospectionTokenVerifier(TokenVerifier): + """Example token verifier that uses OAuth 2.0 Token Introspection (RFC 7662). + + This is a simple example implementation for demonstration purposes. + Production implementations should consider: + - Connection pooling and reuse + - More sophisticated error handling + - Rate limiting and retry logic + - Comprehensive configuration options + """ + + def __init__( + self, + introspection_endpoint: str, + server_url: str, + validate_resource: bool = False, + ): + self.introspection_endpoint = introspection_endpoint + self.server_url = server_url + self.validate_resource = validate_resource + self.resource_url = resource_url_from_server_url(server_url) + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify token via introspection endpoint.""" + import httpx + + # Validate URL to prevent SSRF attacks + if not self.introspection_endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1")): + logger.warning(f"Rejecting introspection endpoint with unsafe scheme: {self.introspection_endpoint}") + return None + + # Configure secure HTTP client + timeout = httpx.Timeout(10.0, connect=5.0) + limits = httpx.Limits(max_connections=10, max_keepalive_connections=5) + + async with httpx.AsyncClient( + timeout=timeout, + limits=limits, + verify=True, # Enforce SSL verification + ) as client: + try: + response = await client.post( + self.introspection_endpoint, + data={"token": token}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + if response.status_code != 200: + logger.debug(f"Token introspection returned status {response.status_code}") + return None + + data = response.json() + if not data.get("active", False): + return None + + # RFC 8707 resource validation (only when --oauth-strict is set) + if self.validate_resource and not self._validate_resource(data): + logger.warning(f"Token resource validation failed. Expected: {self.resource_url}") + return None + + return AccessToken( + token=token, + client_id=data.get("client_id", "unknown"), + scopes=data.get("scope", "").split() if data.get("scope") else [], + expires_at=data.get("exp"), + resource=data.get("aud"), # Include resource in token + subject=data.get("sub"), # RFC 7662 subject (resource owner) + claims=data, + ) + except Exception as e: + logger.warning(f"Token introspection failed: {e}") + return None + + def _validate_resource(self, token_data: dict[str, Any]) -> bool: + """Validate token was issued for this resource server.""" + if not self.server_url or not self.resource_url: + return False # Fail if strict validation requested but URLs missing + + # Check 'aud' claim first (standard JWT audience) + aud: list[str] | str | None = token_data.get("aud") + if isinstance(aud, list): + for audience in aud: + if self._is_valid_resource(audience): + return True + return False + elif aud: + return self._is_valid_resource(aud) + + # No resource binding - invalid per RFC 8707 + return False + + def _is_valid_resource(self, resource: str) -> bool: + """Check if resource matches this server using hierarchical matching.""" + if not self.resource_url: + return False + + return check_resource_allowed(requested_resource=self.resource_url, configured_resource=resource) diff --git a/examples/servers/simple-auth/pyproject.toml b/examples/servers/simple-auth/pyproject.toml new file mode 100644 index 0000000..1ffe3e6 --- /dev/null +++ b/examples/servers/simple-auth/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "mcp-simple-auth" +version = "0.1.0" +description = "A simple MCP server demonstrating OAuth authentication" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +license = { text = "MIT" } +dependencies = [ + "anyio>=4.5", + "click>=8.2.0", + "httpx>=0.27", + "mcp", + "pydantic>=2.0", + "pydantic-settings>=2.5.2", + "sse-starlette>=1.6.1", + "uvicorn>=0.23.1; sys_platform != 'emscripten'", +] + +[project.scripts] +mcp-simple-auth-rs = "mcp_simple_auth.server:main" +mcp-simple-auth-as = "mcp_simple_auth.auth_server:main" +mcp-simple-auth-legacy = "mcp_simple_auth.legacy_as_server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_auth"] + +[dependency-groups] +dev = ["pyright>=1.1.391", "pytest>=8.3.4", "ruff>=0.8.5"] diff --git a/examples/servers/simple-pagination/README.md b/examples/servers/simple-pagination/README.md new file mode 100644 index 0000000..4cab40f --- /dev/null +++ b/examples/servers/simple-pagination/README.md @@ -0,0 +1,77 @@ +# MCP Simple Pagination + +A simple MCP server demonstrating pagination for tools, resources, and prompts using cursor-based pagination. + +## Usage + +Start the server using either stdio (default) or Streamable HTTP transport: + +```bash +# Using stdio transport (default) +uv run mcp-simple-pagination + +# Using Streamable HTTP transport on custom port +uv run mcp-simple-pagination --transport streamable-http --port 8000 +``` + +The server exposes: + +- 25 tools (paginated, 5 per page) +- 30 resources (paginated, 10 per page) +- 20 prompts (paginated, 7 per page) + +Each paginated list returns a `nextCursor` when more pages are available. Use this cursor in subsequent requests to retrieve the next page. + +## Example + +Using the MCP client, you can retrieve paginated items like this using the STDIO transport: + +```python +import asyncio +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client + + +async def main(): + async with stdio_client( + StdioServerParameters(command="uv", args=["run", "mcp-simple-pagination"]) + ) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + # Get first page of tools + tools_page1 = await session.list_tools() + print(f"First page: {len(tools_page1.tools)} tools") + print(f"Next cursor: {tools_page1.nextCursor}") + + # Get second page using cursor + if tools_page1.nextCursor: + tools_page2 = await session.list_tools(cursor=tools_page1.nextCursor) + print(f"Second page: {len(tools_page2.tools)} tools") + + # Similarly for resources + resources_page1 = await session.list_resources() + print(f"First page: {len(resources_page1.resources)} resources") + + # And for prompts + prompts_page1 = await session.list_prompts() + print(f"First page: {len(prompts_page1.prompts)} prompts") + + +asyncio.run(main()) +``` + +## Pagination Details + +The server uses simple numeric indices as cursors for demonstration purposes. In production scenarios, you might use: + +- Database offsets or row IDs +- Timestamps for time-based pagination +- Opaque tokens encoding pagination state + +The pagination implementation demonstrates: + +- Handling `None` cursor for the first page +- Returning `nextCursor` when more data exists +- Gracefully handling invalid cursors +- Different page sizes for different resource types diff --git a/examples/servers/simple-pagination/mcp_simple_pagination/__init__.py b/examples/servers/simple-pagination/mcp_simple_pagination/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/servers/simple-pagination/mcp_simple_pagination/__main__.py b/examples/servers/simple-pagination/mcp_simple_pagination/__main__.py new file mode 100644 index 0000000..e7ef165 --- /dev/null +++ b/examples/servers/simple-pagination/mcp_simple_pagination/__main__.py @@ -0,0 +1,5 @@ +import sys + +from .server import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-pagination/mcp_simple_pagination/server.py b/examples/servers/simple-pagination/mcp_simple_pagination/server.py new file mode 100644 index 0000000..9aca87f --- /dev/null +++ b/examples/servers/simple-pagination/mcp_simple_pagination/server.py @@ -0,0 +1,176 @@ +"""Simple MCP server demonstrating pagination for tools, resources, and prompts. + +This example shows how to implement pagination with the low-level server API +to handle large lists of items that need to be split across multiple pages. +""" + +from typing import TypeVar + +import anyio +import click +import mcp_types as types +from mcp.server import Server, ServerRequestContext + +T = TypeVar("T") + +# Sample data - in real scenarios, this might come from a database +SAMPLE_TOOLS = [ + types.Tool( + name=f"tool_{i}", + title=f"Tool {i}", + description=f"This is sample tool number {i}", + input_schema={"type": "object", "properties": {"input": {"type": "string"}}}, + ) + for i in range(1, 26) # 25 tools total +] + +SAMPLE_RESOURCES = [ + types.Resource( + uri=f"file:///path/to/resource_{i}.txt", + name=f"resource_{i}", + description=f"This is sample resource number {i}", + ) + for i in range(1, 31) # 30 resources total +] + +SAMPLE_PROMPTS = [ + types.Prompt( + name=f"prompt_{i}", + description=f"This is sample prompt number {i}", + arguments=[ + types.PromptArgument(name="arg1", description="First argument", required=True), + ], + ) + for i in range(1, 21) # 20 prompts total +] + + +def _paginate(cursor: str | None, items: list[T], page_size: int) -> tuple[list[T], str | None]: + """Helper to paginate a list of items given a cursor.""" + if cursor is not None: + try: + start_idx = int(cursor) + except (ValueError, TypeError): + return [], None + else: + start_idx = 0 + + page = items[start_idx : start_idx + page_size] + next_cursor = str(start_idx + page_size) if start_idx + page_size < len(items) else None + return page, next_cursor + + +# Paginated list_tools - returns 5 tools per page +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + cursor = params.cursor if params is not None else None + page, next_cursor = _paginate(cursor, SAMPLE_TOOLS, page_size=5) + return types.ListToolsResult(tools=page, next_cursor=next_cursor) + + +# Paginated list_resources - returns 10 resources per page +async def handle_list_resources( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListResourcesResult: + cursor = params.cursor if params is not None else None + page, next_cursor = _paginate(cursor, SAMPLE_RESOURCES, page_size=10) + return types.ListResourcesResult(resources=page, next_cursor=next_cursor) + + +# Paginated list_prompts - returns 7 prompts per page +async def handle_list_prompts( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListPromptsResult: + cursor = params.cursor if params is not None else None + page, next_cursor = _paginate(cursor, SAMPLE_PROMPTS, page_size=7) + return types.ListPromptsResult(prompts=page, next_cursor=next_cursor) + + +async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + # Find the tool in our sample data + tool = next((t for t in SAMPLE_TOOLS if t.name == params.name), None) + if not tool: + raise ValueError(f"Unknown tool: {params.name}") + + return types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=f"Called tool '{params.name}' with arguments: {params.arguments}", + ) + ] + ) + + +async def handle_read_resource( + ctx: ServerRequestContext, params: types.ReadResourceRequestParams +) -> types.ReadResourceResult: + resource = next((r for r in SAMPLE_RESOURCES if r.uri == str(params.uri)), None) + if not resource: + raise ValueError(f"Unknown resource: {params.uri}") + + return types.ReadResourceResult( + contents=[ + types.TextResourceContents( + uri=str(params.uri), + text=f"Content of {resource.name}: This is sample content for the resource.", + mime_type="text/plain", + ) + ] + ) + + +async def handle_get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> types.GetPromptResult: + prompt = next((p for p in SAMPLE_PROMPTS if p.name == params.name), None) + if not prompt: + raise ValueError(f"Unknown prompt: {params.name}") + + message_text = f"This is the prompt '{params.name}'" + if params.arguments: + message_text += f" with arguments: {params.arguments}" + + return types.GetPromptResult( + description=prompt.description, + messages=[ + types.PromptMessage( + role="user", + content=types.TextContent(type="text", text=message_text), + ) + ], + ) + + +@click.command() +@click.option("--port", default=8000, help="Port to listen on for HTTP") +@click.option( + "--transport", + type=click.Choice(["stdio", "streamable-http"]), + default="stdio", + help="Transport type", +) +def main(port: int, transport: str) -> int: + app = Server( + "mcp-simple-pagination", + on_list_tools=handle_list_tools, + on_list_resources=handle_list_resources, + on_list_prompts=handle_list_prompts, + on_call_tool=handle_call_tool, + on_read_resource=handle_read_resource, + on_get_prompt=handle_get_prompt, + ) + + if transport == "streamable-http": + import uvicorn + + uvicorn.run(app.streamable_http_app(), host="127.0.0.1", port=port) + else: + from mcp.server.stdio import stdio_server + + async def arun(): + async with stdio_server() as streams: + await app.run(streams[0], streams[1], app.create_initialization_options()) + + anyio.run(arun) + + return 0 diff --git a/examples/servers/simple-pagination/pyproject.toml b/examples/servers/simple-pagination/pyproject.toml new file mode 100644 index 0000000..2d57d9c --- /dev/null +++ b/examples/servers/simple-pagination/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "mcp-simple-pagination" +version = "0.1.0" +description = "A simple MCP server demonstrating pagination for tools, resources, and prompts" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "automation", "pagination", "cursor"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] + +[project.scripts] +mcp-simple-pagination = "mcp_simple_pagination.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_pagination"] + +[tool.pyright] +include = ["mcp_simple_pagination"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-prompt/README.md b/examples/servers/simple-prompt/README.md new file mode 100644 index 0000000..c837da8 --- /dev/null +++ b/examples/servers/simple-prompt/README.md @@ -0,0 +1,55 @@ +# MCP Simple Prompt + +A simple MCP server that exposes a customizable prompt template with optional context and topic parameters. + +## Usage + +Start the server using either stdio (default) or Streamable HTTP transport: + +```bash +# Using stdio transport (default) +uv run mcp-simple-prompt + +# Using Streamable HTTP transport on custom port +uv run mcp-simple-prompt --transport streamable-http --port 8000 +``` + +The server exposes a prompt named "simple" that accepts two optional arguments: + +- `context`: Additional context to consider +- `topic`: Specific topic to focus on + +## Example + +Using the MCP client, you can retrieve the prompt like this using the STDIO transport: + +```python +import asyncio +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client + + +async def main(): + async with stdio_client( + StdioServerParameters(command="uv", args=["run", "mcp-simple-prompt"]) + ) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + # List available prompts + prompts = await session.list_prompts() + print(prompts) + + # Get the prompt with arguments + prompt = await session.get_prompt( + "simple", + { + "context": "User is a software developer", + "topic": "Python async programming", + }, + ) + print(prompt) + + +asyncio.run(main()) +``` diff --git a/examples/servers/simple-prompt/mcp_simple_prompt/__init__.py b/examples/servers/simple-prompt/mcp_simple_prompt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/servers/simple-prompt/mcp_simple_prompt/__main__.py b/examples/servers/simple-prompt/mcp_simple_prompt/__main__.py new file mode 100644 index 0000000..e7ef165 --- /dev/null +++ b/examples/servers/simple-prompt/mcp_simple_prompt/__main__.py @@ -0,0 +1,5 @@ +import sys + +from .server import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-prompt/mcp_simple_prompt/server.py b/examples/servers/simple-prompt/mcp_simple_prompt/server.py new file mode 100644 index 0000000..31e3eb7 --- /dev/null +++ b/examples/servers/simple-prompt/mcp_simple_prompt/server.py @@ -0,0 +1,98 @@ +import anyio +import click +import mcp_types as types +from mcp.server import Server, ServerRequestContext + + +def create_messages(context: str | None = None, topic: str | None = None) -> list[types.PromptMessage]: + """Create the messages for the prompt.""" + messages: list[types.PromptMessage] = [] + + # Add context if provided + if context: + messages.append( + types.PromptMessage( + role="user", + content=types.TextContent(type="text", text=f"Here is some relevant context: {context}"), + ) + ) + + # Add the main prompt + prompt = "Please help me with " + if topic: + prompt += f"the following topic: {topic}" + else: + prompt += "whatever questions I may have." + + messages.append(types.PromptMessage(role="user", content=types.TextContent(type="text", text=prompt))) + + return messages + + +async def handle_list_prompts( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListPromptsResult: + return types.ListPromptsResult( + prompts=[ + types.Prompt( + name="simple", + title="Simple Assistant Prompt", + description="A simple prompt that can take optional context and topic arguments", + arguments=[ + types.PromptArgument( + name="context", + description="Additional context to consider", + required=False, + ), + types.PromptArgument( + name="topic", + description="Specific topic to focus on", + required=False, + ), + ], + ) + ] + ) + + +async def handle_get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> types.GetPromptResult: + if params.name != "simple": + raise ValueError(f"Unknown prompt: {params.name}") + + arguments = params.arguments or {} + + return types.GetPromptResult( + messages=create_messages(context=arguments.get("context"), topic=arguments.get("topic")), + description="A simple prompt with optional context and topic arguments", + ) + + +@click.command() +@click.option("--port", default=8000, help="Port to listen on for HTTP") +@click.option( + "--transport", + type=click.Choice(["stdio", "streamable-http"]), + default="stdio", + help="Transport type", +) +def main(port: int, transport: str) -> int: + app = Server( + "mcp-simple-prompt", + on_list_prompts=handle_list_prompts, + on_get_prompt=handle_get_prompt, + ) + + if transport == "streamable-http": + import uvicorn + + uvicorn.run(app.streamable_http_app(), host="127.0.0.1", port=port) + else: + from mcp.server.stdio import stdio_server + + async def arun(): + async with stdio_server() as streams: + await app.run(streams[0], streams[1], app.create_initialization_options()) + + anyio.run(arun) + + return 0 diff --git a/examples/servers/simple-prompt/pyproject.toml b/examples/servers/simple-prompt/pyproject.toml new file mode 100644 index 0000000..9d4d8e6 --- /dev/null +++ b/examples/servers/simple-prompt/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "mcp-simple-prompt" +version = "0.1.0" +description = "A simple MCP server exposing a customizable prompt" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "automation", "web", "fetch"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] + +[project.scripts] +mcp-simple-prompt = "mcp_simple_prompt.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_prompt"] + +[tool.pyright] +include = ["mcp_simple_prompt"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-resource/README.md b/examples/servers/simple-resource/README.md new file mode 100644 index 0000000..1d8fb7c --- /dev/null +++ b/examples/servers/simple-resource/README.md @@ -0,0 +1,48 @@ +# MCP Simple Resource + +A simple MCP server that exposes sample text files as resources. + +## Usage + +Start the server using either stdio (default) or Streamable HTTP transport: + +```bash +# Using stdio transport (default) +uv run mcp-simple-resource + +# Using Streamable HTTP transport on custom port +uv run mcp-simple-resource --transport streamable-http --port 8000 +``` + +The server exposes some basic text file resources that can be read by clients. + +## Example + +Using the MCP client, you can retrieve resources like this using the STDIO transport: + +```python +import asyncio +from pydantic import AnyUrl +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client + + +async def main(): + async with stdio_client( + StdioServerParameters(command="uv", args=["run", "mcp-simple-resource"]) + ) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + # List available resources + resources = await session.list_resources() + print(resources) + + # Get a specific resource + resource = await session.read_resource(AnyUrl("file:///greeting.txt")) + print(resource) + + +asyncio.run(main()) + +``` diff --git a/examples/servers/simple-resource/mcp_simple_resource/__init__.py b/examples/servers/simple-resource/mcp_simple_resource/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/servers/simple-resource/mcp_simple_resource/__main__.py b/examples/servers/simple-resource/mcp_simple_resource/__main__.py new file mode 100644 index 0000000..e7ef165 --- /dev/null +++ b/examples/servers/simple-resource/mcp_simple_resource/__main__.py @@ -0,0 +1,5 @@ +import sys + +from .server import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-resource/mcp_simple_resource/server.py b/examples/servers/simple-resource/mcp_simple_resource/server.py new file mode 100644 index 0000000..fe9dcfb --- /dev/null +++ b/examples/servers/simple-resource/mcp_simple_resource/server.py @@ -0,0 +1,91 @@ +from urllib.parse import urlparse + +import anyio +import click +import mcp_types as types +from mcp.server import Server, ServerRequestContext + +SAMPLE_RESOURCES = { + "greeting": { + "content": "Hello! This is a sample text resource.", + "title": "Welcome Message", + }, + "help": { + "content": "This server provides a few sample text resources for testing.", + "title": "Help Documentation", + }, + "about": { + "content": "This is the simple-resource MCP server implementation.", + "title": "About This Server", + }, +} + + +async def handle_list_resources( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListResourcesResult: + return types.ListResourcesResult( + resources=[ + types.Resource( + uri=f"file:///{name}.txt", + name=name, + title=SAMPLE_RESOURCES[name]["title"], + description=f"A sample text resource named {name}", + mime_type="text/plain", + ) + for name in SAMPLE_RESOURCES.keys() + ] + ) + + +async def handle_read_resource( + ctx: ServerRequestContext, params: types.ReadResourceRequestParams +) -> types.ReadResourceResult: + parsed = urlparse(str(params.uri)) + if not parsed.path: + raise ValueError(f"Invalid resource path: {params.uri}") + name = parsed.path.replace(".txt", "").lstrip("/") + + if name not in SAMPLE_RESOURCES: + raise ValueError(f"Unknown resource: {params.uri}") + + return types.ReadResourceResult( + contents=[ + types.TextResourceContents( + uri=str(params.uri), + text=SAMPLE_RESOURCES[name]["content"], + mime_type="text/plain", + ) + ] + ) + + +@click.command() +@click.option("--port", default=8000, help="Port to listen on for HTTP") +@click.option( + "--transport", + type=click.Choice(["stdio", "streamable-http"]), + default="stdio", + help="Transport type", +) +def main(port: int, transport: str) -> int: + app = Server( + "mcp-simple-resource", + on_list_resources=handle_list_resources, + on_read_resource=handle_read_resource, + ) + + if transport == "streamable-http": + import uvicorn + + uvicorn.run(app.streamable_http_app(), host="127.0.0.1", port=port) + else: + from mcp.server.stdio import stdio_server + + async def arun(): + async with stdio_server() as streams: + await app.run(streams[0], streams[1], app.create_initialization_options()) + + anyio.run(arun) + + return 0 diff --git a/examples/servers/simple-resource/pyproject.toml b/examples/servers/simple-resource/pyproject.toml new file mode 100644 index 0000000..34fbc8d --- /dev/null +++ b/examples/servers/simple-resource/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "mcp-simple-resource" +version = "0.1.0" +description = "A simple MCP server exposing sample text resources" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "automation", "web", "fetch"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] + +[project.scripts] +mcp-simple-resource = "mcp_simple_resource.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_resource"] + +[tool.pyright] +include = ["mcp_simple_resource"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-streamablehttp-stateless/README.md b/examples/servers/simple-streamablehttp-stateless/README.md new file mode 100644 index 0000000..a254f88 --- /dev/null +++ b/examples/servers/simple-streamablehttp-stateless/README.md @@ -0,0 +1,38 @@ +# MCP Simple StreamableHttp Stateless Server Example + +A stateless MCP server example demonstrating the StreamableHttp transport without maintaining session state. This example is ideal for understanding how to deploy MCP servers in multi-node environments where requests can be routed to any instance. + +## Features + +- Uses the StreamableHTTP transport in stateless mode (mcp_session_id=None) +- Each request creates a new ephemeral connection +- No session state maintained between requests +- Suitable for deployment in multi-node environments + +## Usage + +Start the server: + +```bash +# Using default port 3000 +uv run mcp-simple-streamablehttp-stateless + +# Using custom port +uv run mcp-simple-streamablehttp-stateless --port 3000 + +# Custom logging level +uv run mcp-simple-streamablehttp-stateless --log-level DEBUG + +# Enable JSON responses instead of SSE streams +uv run mcp-simple-streamablehttp-stateless --json-response +``` + +The server exposes a tool named "start-notification-stream" that accepts three arguments: + +- `interval`: Time between notifications in seconds (e.g., 1.0) +- `count`: Number of notifications to send (e.g., 5) +- `caller`: Identifier string for the caller + +## Client + +You can connect to this server using an HTTP client. For now, only the TypeScript SDK has streamable HTTP client examples, or you can use [Inspector](https://github.com/modelcontextprotocol/inspector) for testing. diff --git a/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__init__.py b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__main__.py b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__main__.py new file mode 100644 index 0000000..1664737 --- /dev/null +++ b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__main__.py @@ -0,0 +1,7 @@ +from .server import main + +if __name__ == "__main__": + # Click will handle CLI arguments + import sys + + sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py new file mode 100644 index 0000000..9df18cc --- /dev/null +++ b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py @@ -0,0 +1,116 @@ +import logging + +import anyio +import click +import mcp_types as types +import uvicorn +from mcp.server import Server, ServerRequestContext +from starlette.middleware.cors import CORSMiddleware + +logger = logging.getLogger(__name__) + + +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="start-notification-stream", + description=("Sends a stream of notifications with configurable count and interval"), + input_schema={ + "type": "object", + "required": ["interval", "count", "caller"], + "properties": { + "interval": { + "type": "number", + "description": "Interval between notifications in seconds", + }, + "count": { + "type": "number", + "description": "Number of notifications to send", + }, + "caller": { + "type": "string", + "description": ("Identifier of the caller to include in notifications"), + }, + }, + }, + ) + ] + ) + + +async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + arguments = params.arguments or {} + interval = arguments.get("interval", 1.0) + count = arguments.get("count", 5) + caller = arguments.get("caller", "unknown") + + # Send the specified number of notifications with the given interval + for i in range(count): + await ctx.session.send_log_message( # pyright: ignore[reportDeprecated] + level="info", + data=f"Notification {i + 1}/{count} from caller: {caller}", + logger="notification_stream", + related_request_id=ctx.request_id, + ) + if i < count - 1: # Don't wait after the last notification + await anyio.sleep(interval) + + return types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=(f"Sent {count} notifications with {interval}s interval for caller: {caller}"), + ) + ] + ) + + +@click.command() +@click.option("--port", default=3000, help="Port to listen on for HTTP") +@click.option( + "--log-level", + default="INFO", + help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)", +) +@click.option( + "--json-response", + is_flag=True, + default=False, + help="Enable JSON responses instead of SSE streams", +) +def main( + port: int, + log_level: str, + json_response: bool, +) -> None: + # Configure logging + logging.basicConfig( + level=getattr(logging, log_level.upper()), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + app = Server( + "mcp-streamable-http-stateless-demo", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, + ) + + starlette_app = app.streamable_http_app( + stateless_http=True, + json_response=json_response, + debug=True, + ) + + # Wrap ASGI application with CORS middleware to expose Mcp-Session-Id header + # for browser-based clients (ensures 500 errors get proper CORS headers) + starlette_app = CORSMiddleware( + starlette_app, + allow_origins=["*"], # Note: streamable_http_app() enforces localhost-only Origin by default + allow_methods=["GET", "POST", "DELETE"], # MCP streamable HTTP methods + expose_headers=["Mcp-Session-Id"], + ) + + uvicorn.run(starlette_app, host="127.0.0.1", port=port) diff --git a/examples/servers/simple-streamablehttp-stateless/pyproject.toml b/examples/servers/simple-streamablehttp-stateless/pyproject.toml new file mode 100644 index 0000000..38f7b1b --- /dev/null +++ b/examples/servers/simple-streamablehttp-stateless/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "mcp-simple-streamablehttp-stateless" +version = "0.1.0" +description = "A simple MCP server exposing a StreamableHttp transport in stateless mode" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "automation", "web", "fetch", "http", "streamable", "stateless"] +license = { text = "MIT" } +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"] + +[project.scripts] +mcp-simple-streamablehttp-stateless = "mcp_simple_streamablehttp_stateless.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_streamablehttp_stateless"] + +[tool.pyright] +include = ["mcp_simple_streamablehttp_stateless"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-streamablehttp/README.md b/examples/servers/simple-streamablehttp/README.md new file mode 100644 index 0000000..3eed332 --- /dev/null +++ b/examples/servers/simple-streamablehttp/README.md @@ -0,0 +1,51 @@ +# MCP Simple StreamableHttp Server Example + +A simple MCP server example demonstrating the StreamableHttp transport, which enables HTTP-based communication with MCP servers using streaming. + +## Features + +- Uses the StreamableHTTP transport for server-client communication +- Supports REST API operations (POST, GET, DELETE) for `/mcp` endpoint +- Ability to send multiple notifications over time to the client +- Resumability support via InMemoryEventStore + +## Usage + +Start the server on the default or custom port: + +```bash + +# Using custom port +uv run mcp-simple-streamablehttp --port 3000 + +# Custom logging level +uv run mcp-simple-streamablehttp --log-level DEBUG + +# Enable JSON responses instead of SSE streams +uv run mcp-simple-streamablehttp --json-response +``` + +The server exposes a tool named "start-notification-stream" that accepts three arguments: + +- `interval`: Time between notifications in seconds (e.g., 1.0) +- `count`: Number of notifications to send (e.g., 5) +- `caller`: Identifier string for the caller + +## Resumability Support + +This server includes resumability support through the InMemoryEventStore. This enables clients to: + +- Reconnect to the server after a disconnection +- Resume event streaming from where they left off using the Last-Event-ID header + +The server will: + +- Generate unique event IDs for each SSE message +- Store events in memory for later replay +- Replay missed events when a client reconnects with a Last-Event-ID header + +Note: The InMemoryEventStore is designed for demonstration purposes only. For production use, consider implementing a persistent storage solution. + +## Client + +You can connect to this server using an HTTP client, for now only Typescript SDK has streamable HTTP client examples or you can use [Inspector](https://github.com/modelcontextprotocol/inspector) diff --git a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__init__.py b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__main__.py b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__main__.py new file mode 100644 index 0000000..21862e4 --- /dev/null +++ b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__main__.py @@ -0,0 +1,4 @@ +from .server import main + +if __name__ == "__main__": + main() # type: ignore[call-arg] diff --git a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py new file mode 100644 index 0000000..c9369cf --- /dev/null +++ b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py @@ -0,0 +1,93 @@ +"""In-memory event store for demonstrating resumability functionality. + +This is a simple implementation intended for examples and testing, +not for production use where a persistent storage solution would be more appropriate. +""" + +import logging +from collections import deque +from dataclasses import dataclass +from uuid import uuid4 + +from mcp.server.streamable_http import EventCallback, EventId, EventMessage, EventStore, StreamId +from mcp_types import JSONRPCMessage + +logger = logging.getLogger(__name__) + + +@dataclass +class EventEntry: + """Represents an event entry in the event store.""" + + event_id: EventId + stream_id: StreamId + message: JSONRPCMessage | None + + +class InMemoryEventStore(EventStore): + """Simple in-memory implementation of the EventStore interface for resumability. + This is primarily intended for examples and testing, not for production use + where a persistent storage solution would be more appropriate. + + This implementation keeps only the last N events per stream for memory efficiency. + """ + + def __init__(self, max_events_per_stream: int = 100): + """Initialize the event store. + + Args: + max_events_per_stream: Maximum number of events to keep per stream + """ + self.max_events_per_stream = max_events_per_stream + # for maintaining last N events per stream + self.streams: dict[StreamId, deque[EventEntry]] = {} + # event_id -> EventEntry for quick lookup + self.event_index: dict[EventId, EventEntry] = {} + + async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId: + """Stores an event with a generated event ID.""" + event_id = str(uuid4()) + event_entry = EventEntry(event_id=event_id, stream_id=stream_id, message=message) + + # Get or create deque for this stream + if stream_id not in self.streams: + self.streams[stream_id] = deque(maxlen=self.max_events_per_stream) + + # If deque is full, the oldest event will be automatically removed + # We need to remove it from the event_index as well + if len(self.streams[stream_id]) == self.max_events_per_stream: + oldest_event = self.streams[stream_id][0] + self.event_index.pop(oldest_event.event_id, None) + + # Add new event + self.streams[stream_id].append(event_entry) + self.event_index[event_id] = event_entry + + return event_id + + async def replay_events_after( + self, + last_event_id: EventId, + send_callback: EventCallback, + ) -> StreamId | None: + """Replays events that occurred after the specified event ID.""" + if last_event_id not in self.event_index: + logger.warning(f"Event ID {last_event_id} not found in store") + return None + + # Get the stream and find events after the last one + last_event = self.event_index[last_event_id] + stream_id = last_event.stream_id + stream_events = self.streams.get(last_event.stream_id, deque()) + + # Events in deque are already in chronological order + found_last = False + for event in stream_events: + if found_last: + # Skip priming events (None message) + if event.message is not None: + await send_callback(EventMessage(event.message, event.event_id)) + elif event.event_id == last_event_id: + found_last = True + + return stream_id diff --git a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py new file mode 100644 index 0000000..e650b35 --- /dev/null +++ b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py @@ -0,0 +1,142 @@ +import logging + +import anyio +import click +import mcp_types as types +import uvicorn +from mcp.server import Server, ServerRequestContext +from starlette.middleware.cors import CORSMiddleware + +from .event_store import InMemoryEventStore + +# Configure logging +logger = logging.getLogger(__name__) + + +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="start-notification-stream", + description="Sends a stream of notifications with configurable count and interval", + input_schema={ + "type": "object", + "required": ["interval", "count", "caller"], + "properties": { + "interval": { + "type": "number", + "description": "Interval between notifications in seconds", + }, + "count": { + "type": "number", + "description": "Number of notifications to send", + }, + "caller": { + "type": "string", + "description": "Identifier of the caller to include in notifications", + }, + }, + }, + ) + ] + ) + + +async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + arguments = params.arguments or {} + interval = arguments.get("interval", 1.0) + count = arguments.get("count", 5) + caller = arguments.get("caller", "unknown") + + # Send the specified number of notifications with the given interval + for i in range(count): + # Include more detailed message for resumability demonstration + notification_msg = f"[{i + 1}/{count}] Event from '{caller}' - Use Last-Event-ID to resume if disconnected" + await ctx.session.send_log_message( # pyright: ignore[reportDeprecated] + level="info", + data=notification_msg, + logger="notification_stream", + # Associates this notification with the original request + # Ensures notifications are sent to the correct response stream + # Without this, notifications will either go to: + # - a standalone SSE stream (if GET request is supported) + # - nowhere (if GET request isn't supported) + related_request_id=ctx.request_id, + ) + logger.debug(f"Sent notification {i + 1}/{count} for caller: {caller}") + if i < count - 1: # Don't wait after the last notification + await anyio.sleep(interval) + + # This will send a resource notification through standalone SSE + # established by GET request + await ctx.session.send_resource_updated(uri="http:///test_resource") + return types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=(f"Sent {count} notifications with {interval}s interval for caller: {caller}"), + ) + ] + ) + + +@click.command() +@click.option("--port", default=3000, help="Port to listen on for HTTP") +@click.option( + "--log-level", + default="INFO", + help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)", +) +@click.option( + "--json-response", + is_flag=True, + default=False, + help="Enable JSON responses instead of SSE streams", +) +def main( + port: int, + log_level: str, + json_response: bool, +) -> int: + # Configure logging + logging.basicConfig( + level=getattr(logging, log_level.upper()), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + app = Server( + "mcp-streamable-http-demo", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, + ) + + # Create event store for resumability + # The InMemoryEventStore enables resumability support for StreamableHTTP transport. + # It stores SSE events with unique IDs, allowing clients to: + # 1. Receive event IDs for each SSE message + # 2. Resume streams by sending Last-Event-ID in GET requests + # 3. Replay missed events after reconnection + # Note: This in-memory implementation is for demonstration ONLY. + # For production, use a persistent storage solution. + event_store = InMemoryEventStore() + + starlette_app = app.streamable_http_app( + event_store=event_store, + json_response=json_response, + debug=True, + ) + + # Wrap ASGI application with CORS middleware to expose Mcp-Session-Id header + # for browser-based clients (ensures 500 errors get proper CORS headers) + starlette_app = CORSMiddleware( + starlette_app, + allow_origins=["*"], # Note: streamable_http_app() enforces localhost-only Origin by default + allow_methods=["GET", "POST", "DELETE"], # MCP streamable HTTP methods + expose_headers=["Mcp-Session-Id"], + ) + + uvicorn.run(starlette_app, host="127.0.0.1", port=port) + + return 0 diff --git a/examples/servers/simple-streamablehttp/pyproject.toml b/examples/servers/simple-streamablehttp/pyproject.toml new file mode 100644 index 0000000..93f7baf --- /dev/null +++ b/examples/servers/simple-streamablehttp/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "mcp-simple-streamablehttp" +version = "0.1.0" +description = "A simple MCP server exposing a StreamableHttp transport for testing" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "automation", "web", "fetch", "http", "streamable"] +license = { text = "MIT" } +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"] + +[project.scripts] +mcp-simple-streamablehttp = "mcp_simple_streamablehttp.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_streamablehttp"] + +[tool.pyright] +include = ["mcp_simple_streamablehttp"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-tool/README.md b/examples/servers/simple-tool/README.md new file mode 100644 index 0000000..7d3759f --- /dev/null +++ b/examples/servers/simple-tool/README.md @@ -0,0 +1,48 @@ + +A simple MCP server that exposes a website fetching tool. + +## Usage + +Start the server using either stdio (default) or Streamable HTTP transport: + +```bash +# Using stdio transport (default) +uv run mcp-simple-tool + +# Using Streamable HTTP transport on custom port +uv run mcp-simple-tool --transport streamable-http --port 8000 +``` + +The server exposes a tool named "fetch" that accepts one required argument: + +- `url`: The URL of the website to fetch + +## Example + +Using the MCP client, you can use the tool like this using the STDIO transport: + +```python +import asyncio +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client + + +async def main(): + async with stdio_client( + StdioServerParameters(command="uv", args=["run", "mcp-simple-tool"]) + ) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + # List available tools + tools = await session.list_tools() + print(tools) + + # Call the fetch tool + result = await session.call_tool("fetch", {"url": "https://example.com"}) + print(result) + + +asyncio.run(main()) + +``` diff --git a/examples/servers/simple-tool/mcp_simple_tool/__init__.py b/examples/servers/simple-tool/mcp_simple_tool/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/servers/simple-tool/mcp_simple_tool/__main__.py b/examples/servers/simple-tool/mcp_simple_tool/__main__.py new file mode 100644 index 0000000..e7ef165 --- /dev/null +++ b/examples/servers/simple-tool/mcp_simple_tool/__main__.py @@ -0,0 +1,5 @@ +import sys + +from .server import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-tool/mcp_simple_tool/server.py b/examples/servers/simple-tool/mcp_simple_tool/server.py new file mode 100644 index 0000000..b16249e --- /dev/null +++ b/examples/servers/simple-tool/mcp_simple_tool/server.py @@ -0,0 +1,80 @@ +import anyio +import click +import mcp_types as types +from mcp.server import Server, ServerRequestContext +from mcp.shared._httpx_utils import create_mcp_http_client + + +async def fetch_website( + url: str, +) -> list[types.ContentBlock]: + headers = {"User-Agent": "MCP Test Server (github.com/modelcontextprotocol/python-sdk)"} + async with create_mcp_http_client(headers=headers) as client: + response = await client.get(url) + response.raise_for_status() + return [types.TextContent(type="text", text=response.text)] + + +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="fetch", + title="Website Fetcher", + description="Fetches a website and returns its content", + input_schema={ + "type": "object", + "required": ["url"], + "properties": { + "url": { + "type": "string", + "description": "URL to fetch", + } + }, + }, + ) + ] + ) + + +async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + if params.name != "fetch": + raise ValueError(f"Unknown tool: {params.name}") + arguments = params.arguments or {} + if "url" not in arguments: + raise ValueError("Missing required argument 'url'") + content = await fetch_website(arguments["url"]) + return types.CallToolResult(content=content) + + +@click.command() +@click.option("--port", default=8000, help="Port to listen on for HTTP") +@click.option( + "--transport", + type=click.Choice(["stdio", "streamable-http"]), + default="stdio", + help="Transport type", +) +def main(port: int, transport: str) -> int: + app = Server( + "mcp-website-fetcher", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, + ) + + if transport == "streamable-http": + import uvicorn + + uvicorn.run(app.streamable_http_app(), host="127.0.0.1", port=port) + else: + from mcp.server.stdio import stdio_server + + async def arun(): + async with stdio_server() as streams: + await app.run(streams[0], streams[1], app.create_initialization_options()) + + anyio.run(arun) + + return 0 diff --git a/examples/servers/simple-tool/pyproject.toml b/examples/servers/simple-tool/pyproject.toml new file mode 100644 index 0000000..022e039 --- /dev/null +++ b/examples/servers/simple-tool/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "mcp-simple-tool" +version = "0.1.0" +description = "A simple MCP server exposing a website fetching tool" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "automation", "web", "fetch"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] + +[project.scripts] +mcp-simple-tool = "mcp_simple_tool.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_tool"] + +[tool.pyright] +include = ["mcp_simple_tool"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/sse-polling-demo/README.md b/examples/servers/sse-polling-demo/README.md new file mode 100644 index 0000000..e9d4446 --- /dev/null +++ b/examples/servers/sse-polling-demo/README.md @@ -0,0 +1,36 @@ +# MCP SSE Polling Demo Server + +Demonstrates the SSE polling pattern with server-initiated stream close for long-running tasks (SEP-1699). + +## Features + +- Priming events (automatic with EventStore) +- Server-initiated stream close via `close_sse_stream()` callback +- Client auto-reconnect with Last-Event-ID +- Progress notifications during long-running tasks +- Configurable retry interval + +## Usage + +```bash +# Start server on default port +uv run mcp-sse-polling-demo --port 3000 + +# Custom retry interval (milliseconds) +uv run mcp-sse-polling-demo --port 3000 --retry-interval 100 +``` + +## Tool: process_batch + +Processes items with periodic checkpoints that trigger SSE stream closes: + +- `items`: Number of items to process (1-100, default: 10) +- `checkpoint_every`: Close stream after this many items (1-20, default: 3) + +## Client + +Use the companion `mcp-sse-polling-client` to test: + +```bash +uv run mcp-sse-polling-client --url http://localhost:3000/mcp +``` diff --git a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__init__.py b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__init__.py new file mode 100644 index 0000000..46af2fd --- /dev/null +++ b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__init__.py @@ -0,0 +1 @@ +"""SSE Polling Demo Server - demonstrates close_sse_stream for long-running tasks.""" diff --git a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__main__.py b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__main__.py new file mode 100644 index 0000000..23cfc85 --- /dev/null +++ b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__main__.py @@ -0,0 +1,6 @@ +"""Entry point for the SSE Polling Demo server.""" + +from .server import main + +if __name__ == "__main__": + main() diff --git a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/event_store.py b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/event_store.py new file mode 100644 index 0000000..e2cca4a --- /dev/null +++ b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/event_store.py @@ -0,0 +1,98 @@ +"""In-memory event store for demonstrating resumability functionality. + +This is a simple implementation intended for examples and testing, +not for production use where a persistent storage solution would be more appropriate. +""" + +import logging +from collections import deque +from dataclasses import dataclass +from uuid import uuid4 + +from mcp.server.streamable_http import EventCallback, EventId, EventMessage, EventStore, StreamId +from mcp_types import JSONRPCMessage + +logger = logging.getLogger(__name__) + + +@dataclass +class EventEntry: + """Represents an event entry in the event store.""" + + event_id: EventId + stream_id: StreamId + message: JSONRPCMessage | None # None for priming events + + +class InMemoryEventStore(EventStore): + """Simple in-memory implementation of the EventStore interface for resumability. + This is primarily intended for examples and testing, not for production use + where a persistent storage solution would be more appropriate. + + This implementation keeps only the last N events per stream for memory efficiency. + """ + + def __init__(self, max_events_per_stream: int = 100): + """Initialize the event store. + + Args: + max_events_per_stream: Maximum number of events to keep per stream + """ + self.max_events_per_stream = max_events_per_stream + # for maintaining last N events per stream + self.streams: dict[StreamId, deque[EventEntry]] = {} + # event_id -> EventEntry for quick lookup + self.event_index: dict[EventId, EventEntry] = {} + + async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId: + """Stores an event with a generated event ID. + + Args: + stream_id: ID of the stream the event belongs to + message: The message to store, or None for priming events + """ + event_id = str(uuid4()) + event_entry = EventEntry(event_id=event_id, stream_id=stream_id, message=message) + + # Get or create deque for this stream + if stream_id not in self.streams: + self.streams[stream_id] = deque(maxlen=self.max_events_per_stream) + + # If deque is full, the oldest event will be automatically removed + # We need to remove it from the event_index as well + if len(self.streams[stream_id]) == self.max_events_per_stream: + oldest_event = self.streams[stream_id][0] + self.event_index.pop(oldest_event.event_id, None) + + # Add new event + self.streams[stream_id].append(event_entry) + self.event_index[event_id] = event_entry + + return event_id + + async def replay_events_after( + self, + last_event_id: EventId, + send_callback: EventCallback, + ) -> StreamId | None: + """Replays events that occurred after the specified event ID.""" + if last_event_id not in self.event_index: + logger.warning(f"Event ID {last_event_id} not found in store") + return None + + # Get the stream and find events after the last one + last_event = self.event_index[last_event_id] + stream_id = last_event.stream_id + stream_events = self.streams.get(last_event.stream_id, deque()) + + # Events in deque are already in chronological order + found_last = False + for event in stream_events: + if found_last: + # Skip priming events (None messages) during replay + if event.message is not None: + await send_callback(EventMessage(event.message, event.event_id)) + elif event.event_id == last_event_id: + found_last = True + + return stream_id diff --git a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/server.py b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/server.py new file mode 100644 index 0000000..7d2c60f --- /dev/null +++ b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/server.py @@ -0,0 +1,160 @@ +"""SSE Polling Demo Server + +Demonstrates the SSE polling pattern with close_sse_stream() for long-running tasks. + +Features demonstrated: +- Priming events (automatic with EventStore) +- Server-initiated stream close via close_sse_stream callback +- Client auto-reconnect with Last-Event-ID +- Progress notifications during long-running tasks + +Run with: + uv run mcp-sse-polling-demo --port 3000 +""" + +import logging + +import anyio +import click +import mcp_types as types +import uvicorn +from mcp.server import Server, ServerRequestContext + +from .event_store import InMemoryEventStore + +logger = logging.getLogger(__name__) + + +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + """List available tools.""" + return types.ListToolsResult( + tools=[ + types.Tool( + name="process_batch", + description=( + "Process a batch of items with periodic checkpoints. " + "Demonstrates SSE polling where server closes stream periodically." + ), + input_schema={ + "type": "object", + "properties": { + "items": { + "type": "integer", + "description": "Number of items to process (1-100)", + "default": 10, + }, + "checkpoint_every": { + "type": "integer", + "description": "Close stream after this many items (1-20)", + "default": 3, + }, + }, + }, + ) + ] + ) + + +async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + """Handle tool calls.""" + arguments = params.arguments or {} + + if params.name == "process_batch": + items = arguments.get("items", 10) + checkpoint_every = arguments.get("checkpoint_every", 3) + + if items < 1 or items > 100: + return types.CallToolResult( + content=[types.TextContent(type="text", text="Error: items must be between 1 and 100")] + ) + if checkpoint_every < 1 or checkpoint_every > 20: + return types.CallToolResult( + content=[types.TextContent(type="text", text="Error: checkpoint_every must be between 1 and 20")] + ) + + await ctx.session.send_log_message( # pyright: ignore[reportDeprecated] + level="info", + data=f"Starting batch processing of {items} items...", + logger="process_batch", + related_request_id=ctx.request_id, + ) + + for i in range(1, items + 1): + # Simulate work + await anyio.sleep(0.5) + + # Report progress + await ctx.session.send_log_message( # pyright: ignore[reportDeprecated] + level="info", + data=f"[{i}/{items}] Processing item {i}", + logger="process_batch", + related_request_id=ctx.request_id, + ) + + # Checkpoint: close stream to trigger client reconnect + if i % checkpoint_every == 0 and i < items: + await ctx.session.send_log_message( # pyright: ignore[reportDeprecated] + level="info", + data=f"Checkpoint at item {i} - closing SSE stream for polling", + logger="process_batch", + related_request_id=ctx.request_id, + ) + if ctx.close_sse_stream: + logger.info(f"Closing SSE stream at checkpoint {i}") + await ctx.close_sse_stream() + # Wait for client to reconnect (must be > retry_interval of 100ms) + await anyio.sleep(0.2) + + return types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=f"Successfully processed {items} items with checkpoints every {checkpoint_every} items", + ) + ] + ) + + return types.CallToolResult(content=[types.TextContent(type="text", text=f"Unknown tool: {params.name}")]) + + +@click.command() +@click.option("--port", default=3000, help="Port to listen on") +@click.option( + "--log-level", + default="INFO", + help="Logging level (DEBUG, INFO, WARNING, ERROR)", +) +@click.option( + "--retry-interval", + default=100, + help="SSE retry interval in milliseconds (sent to client)", +) +def main(port: int, log_level: str, retry_interval: int) -> int: + """Run the SSE Polling Demo server.""" + logging.basicConfig( + level=getattr(logging, log_level.upper()), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + app = Server( + "sse-polling-demo", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, + ) + + starlette_app = app.streamable_http_app( + event_store=InMemoryEventStore(), + retry_interval=retry_interval, + debug=True, + ) + + logger.info(f"SSE Polling Demo server starting on port {port}") + logger.info("Try: POST /mcp with tools/call for 'process_batch'") + uvicorn.run(starlette_app, host="127.0.0.1", port=port) + return 0 + + +if __name__ == "__main__": + main() diff --git a/examples/servers/sse-polling-demo/pyproject.toml b/examples/servers/sse-polling-demo/pyproject.toml new file mode 100644 index 0000000..400f658 --- /dev/null +++ b/examples/servers/sse-polling-demo/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "mcp-sse-polling-demo" +version = "0.1.0" +description = "Demo server showing SSE polling with close_sse_stream for long-running tasks" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "sse", "polling", "streamable", "http"] +license = { text = "MIT" } +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"] + +[project.scripts] +mcp-sse-polling-demo = "mcp_sse_polling_demo.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_sse_polling_demo"] + +[tool.pyright] +include = ["mcp_sse_polling_demo"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__init__.py b/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__init__.py new file mode 100644 index 0000000..c659056 --- /dev/null +++ b/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__init__.py @@ -0,0 +1 @@ +"""Example of structured output with low-level MCP server.""" diff --git a/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__main__.py b/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__main__.py new file mode 100644 index 0000000..393ff7a --- /dev/null +++ b/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__main__.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Example low-level MCP server demonstrating structured output support. + +This example shows how to use the low-level server API to return +structured data from tools. +""" + +import asyncio +import json +import random +from datetime import datetime + +import mcp_types as types + +import mcp.server.stdio +from mcp.server import Server, ServerRequestContext + + +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + """List available tools with their schemas.""" + return types.ListToolsResult( + tools=[ + types.Tool( + name="get_weather", + description="Get weather information (simulated)", + input_schema={ + "type": "object", + "properties": {"city": {"type": "string", "description": "City name"}}, + "required": ["city"], + }, + output_schema={ + "type": "object", + "properties": { + "temperature": {"type": "number"}, + "conditions": {"type": "string"}, + "humidity": {"type": "integer", "minimum": 0, "maximum": 100}, + "wind_speed": {"type": "number"}, + "timestamp": {"type": "string", "format": "date-time"}, + }, + "required": ["temperature", "conditions", "humidity", "wind_speed", "timestamp"], + }, + ), + ] + ) + + +async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + """Handle tool call with structured output.""" + + if params.name == "get_weather": + # Simulate weather data (in production, call a real weather API) + weather_conditions = ["sunny", "cloudy", "rainy", "partly cloudy", "foggy"] + + weather_data = { + "temperature": round(random.uniform(0, 35), 1), + "conditions": random.choice(weather_conditions), + "humidity": random.randint(30, 90), + "wind_speed": round(random.uniform(0, 30), 1), + "timestamp": datetime.now().isoformat(), + } + + return types.CallToolResult( + content=[types.TextContent(type="text", text=json.dumps(weather_data, indent=2))], + structured_content=weather_data, + ) + + raise ValueError(f"Unknown tool: {params.name}") + + +server = Server( + "structured-output-lowlevel-example", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, +) + + +async def run(): + """Run the low-level server using stdio transport.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + server.create_initialization_options(), + ) + + +if __name__ == "__main__": + asyncio.run(run()) diff --git a/examples/servers/structured-output-lowlevel/pyproject.toml b/examples/servers/structured-output-lowlevel/pyproject.toml new file mode 100644 index 0000000..554efc6 --- /dev/null +++ b/examples/servers/structured-output-lowlevel/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "mcp-structured-output-lowlevel" +version = "0.1.0" +description = "Example of structured output with low-level MCP server" +requires-python = ">=3.10" +dependencies = ["mcp"] diff --git a/examples/snippets/clients/__init__.py b/examples/snippets/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/snippets/clients/completion_client.py b/examples/snippets/clients/completion_client.py new file mode 100644 index 0000000..52957d9 --- /dev/null +++ b/examples/snippets/clients/completion_client.py @@ -0,0 +1,78 @@ +"""cd to the `examples/snippets` directory and run: +uv run completion-client +""" + +import asyncio +import os + +from mcp_types import PromptReference, ResourceTemplateReference + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +# Create server parameters for stdio connection +server_params = StdioServerParameters( + command="uv", # Using uv to run the server + args=["run", "server", "completion", "stdio"], # Server with completion support + env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, +) + + +async def run(): + """Run the completion client example.""" + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + # Initialize the connection + await session.initialize() + + # List available resource templates + templates = await session.list_resource_templates() + print("Available resource templates:") + for template in templates.resource_templates: + print(f" - {template.uri_template}") + + # List available prompts + prompts = await session.list_prompts() + print("\nAvailable prompts:") + for prompt in prompts.prompts: + print(f" - {prompt.name}") + + # Complete resource template arguments + if templates.resource_templates: + template = templates.resource_templates[0] + print(f"\nCompleting arguments for resource template: {template.uri_template}") + + # Complete without context + result = await session.complete( + ref=ResourceTemplateReference(type="ref/resource", uri=template.uri_template), + argument={"name": "owner", "value": "model"}, + ) + print(f"Completions for 'owner' starting with 'model': {result.completion.values}") + + # Complete with context - repo suggestions based on owner + result = await session.complete( + ref=ResourceTemplateReference(type="ref/resource", uri=template.uri_template), + argument={"name": "repo", "value": ""}, + context_arguments={"owner": "modelcontextprotocol"}, + ) + print(f"Completions for 'repo' with owner='modelcontextprotocol': {result.completion.values}") + + # Complete prompt arguments + if prompts.prompts: + prompt_name = prompts.prompts[0].name + print(f"\nCompleting arguments for prompt: {prompt_name}") + + result = await session.complete( + ref=PromptReference(type="ref/prompt", name=prompt_name), + argument={"name": "style", "value": ""}, + ) + print(f"Completions for 'style' argument: {result.completion.values}") + + +def main(): + """Entry point for the completion client.""" + asyncio.run(run()) + + +if __name__ == "__main__": + main() diff --git a/examples/snippets/clients/display_utilities.py b/examples/snippets/clients/display_utilities.py new file mode 100644 index 0000000..baa2765 --- /dev/null +++ b/examples/snippets/clients/display_utilities.py @@ -0,0 +1,66 @@ +"""cd to the `examples/snippets` directory and run: +uv run display-utilities-client +""" + +import asyncio +import os + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client +from mcp.shared.metadata_utils import get_display_name + +# Create server parameters for stdio connection +server_params = StdioServerParameters( + command="uv", # Using uv to run the server + args=["run", "server", "mcpserver_quickstart", "stdio"], + env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, +) + + +async def display_tools(session: ClientSession): + """Display available tools with human-readable names""" + tools_response = await session.list_tools() + + for tool in tools_response.tools: + # get_display_name() returns the title if available, otherwise the name + display_name = get_display_name(tool) + print(f"Tool: {display_name}") + if tool.description: + print(f" {tool.description}") + + +async def display_resources(session: ClientSession): + """Display available resources with human-readable names""" + resources_response = await session.list_resources() + + for resource in resources_response.resources: + display_name = get_display_name(resource) + print(f"Resource: {display_name} ({resource.uri})") + + templates_response = await session.list_resource_templates() + for template in templates_response.resource_templates: + display_name = get_display_name(template) + print(f"Resource Template: {display_name}") + + +async def run(): + """Run the display utilities example.""" + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + # Initialize the connection + await session.initialize() + + print("=== Available Tools ===") + await display_tools(session) + + print("\n=== Available Resources ===") + await display_resources(session) + + +def main(): + """Entry point for the display utilities client.""" + asyncio.run(run()) + + +if __name__ == "__main__": + main() diff --git a/examples/snippets/clients/identity_assertion_client.py b/examples/snippets/clients/identity_assertion_client.py new file mode 100644 index 0000000..218df4b --- /dev/null +++ b/examples/snippets/clients/identity_assertion_client.py @@ -0,0 +1,82 @@ +"""Client side of SEP-990 (enterprise IdP policy controls). + +`IdentityAssertionOAuthProvider` presents an Identity Assertion Authorization Grant (ID-JAG) issued +by the enterprise IdP to the MCP authorization server using the RFC 7523 jwt-bearer grant +(`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, ID-JAG as `assertion`), and receives an +MCP access token. No browser redirect or dynamic client registration is involved. + +Obtaining the ID-JAG (logging into the IdP and the leg-1 exchange against it) is deployment-specific +and out of scope for the SDK; supply it through the `assertion_provider` callback. The callback +receives the authorization server's issuer (the ID-JAG `aud`) and the MCP server's resource +identifier (the ID-JAG `resource` claim). SEP-990 requires a confidential client, so a client secret +is mandatory, and `issuer` is the authorization server the credentials are provisioned for - the +provider fetches metadata from that issuer's well-known and never asks the resource server which AS +to use. +""" + +import asyncio + +import httpx + +from mcp import ClientSession +from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken + + +class InMemoryTokenStorage: + """Demo in-memory token storage.""" + + def __init__(self) -> None: + self.tokens: OAuthToken | None = None + self.client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self.tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + self.tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self.client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self.client_info = client_info + + +async def fetch_id_jag(audience: str, resource: str) -> str: + """Return the ID-JAG to present. + + `audience` is the MCP authorization server's issuer (the ID-JAG `aud` claim); `resource` is the + MCP server's RFC 9728 identifier (the ID-JAG `resource` claim, which the AS audience-restricts + the issued token against). In production this exchanges the user's IdP ID token for an ID-JAG + against the enterprise identity provider. + """ + raise NotImplementedError("Obtain the ID-JAG from your enterprise identity provider") + + +async def main() -> None: + oauth_auth = IdentityAssertionOAuthProvider( + server_url="http://localhost:8001/mcp", + storage=InMemoryTokenStorage(), + client_id="enterprise-mcp-client", + client_secret="enterprise-mcp-secret", + issuer="http://localhost:8001", + assertion_provider=fetch_id_jag, + scope="user", + ) + + async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as http_client: + async with streamable_http_client("http://localhost:8001/mcp", http_client=http_client) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + tools = await session.list_tools() + print(f"Available tools: {[tool.name for tool in tools.tools]}") + + +def run() -> None: + asyncio.run(main()) + + +if __name__ == "__main__": + run() diff --git a/examples/snippets/clients/oauth_client.py b/examples/snippets/clients/oauth_client.py new file mode 100644 index 0000000..2085b9a --- /dev/null +++ b/examples/snippets/clients/oauth_client.py @@ -0,0 +1,92 @@ +"""Before running, specify running MCP RS server URL. +To spin up RS server locally, see + examples/servers/simple-auth/README.md + +cd to the `examples/snippets` directory and run: + uv run oauth-client +""" + +import asyncio +from urllib.parse import parse_qs, urlparse + +import httpx +from pydantic import AnyUrl + +from mcp import ClientSession +from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider, TokenStorage +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken + + +class InMemoryTokenStorage(TokenStorage): + """Demo In-memory token storage implementation.""" + + def __init__(self): + self.tokens: OAuthToken | None = None + self.client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + """Get stored tokens.""" + return self.tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + """Store tokens.""" + self.tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + """Get stored client information.""" + return self.client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + """Store client information.""" + self.client_info = client_info + + +async def handle_redirect(auth_url: str) -> None: + print(f"Visit: {auth_url}") + + +async def handle_callback() -> AuthorizationCodeResult: + callback_url = input("Paste callback URL: ") + params = parse_qs(urlparse(callback_url).query) + return AuthorizationCodeResult( + code=params["code"][0], + state=params.get("state", [None])[0], + iss=params.get("iss", [None])[0], + ) + + +async def main(): + """Run the OAuth client example.""" + oauth_auth = OAuthClientProvider( + server_url="http://localhost:8001", + client_metadata=OAuthClientMetadata( + client_name="Example MCP Client", + redirect_uris=[AnyUrl("http://localhost:3000/callback")], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + scope="user", + ), + storage=InMemoryTokenStorage(), + redirect_handler=handle_redirect, + callback_handler=handle_callback, + ) + + async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: + async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + tools = await session.list_tools() + print(f"Available tools: {[tool.name for tool in tools.tools]}") + + resources = await session.list_resources() + print(f"Available resources: {[r.uri for r in resources.resources]}") + + +def run(): + asyncio.run(main()) + + +if __name__ == "__main__": + run() diff --git a/examples/snippets/clients/pagination_client.py b/examples/snippets/clients/pagination_client.py new file mode 100644 index 0000000..00663ef --- /dev/null +++ b/examples/snippets/clients/pagination_client.py @@ -0,0 +1,40 @@ +"""Example of consuming paginated MCP endpoints from a client.""" + +import asyncio + +from mcp_types import PaginatedRequestParams, Resource + +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client + + +async def list_all_resources() -> None: + """Fetch all resources using pagination.""" + async with stdio_client(StdioServerParameters(command="uv", args=["run", "mcp-simple-pagination"])) as ( + read, + write, + ): + async with ClientSession(read, write) as session: + await session.initialize() + + all_resources: list[Resource] = [] + cursor = None + + while True: + # Fetch a page of resources + result = await session.list_resources(params=PaginatedRequestParams(cursor=cursor)) + all_resources.extend(result.resources) + + print(f"Fetched {len(result.resources)} resources") + + # Check if there are more pages + if result.next_cursor: + cursor = result.next_cursor + else: + break + + print(f"Total resources: {len(all_resources)}") + + +if __name__ == "__main__": + asyncio.run(list_all_resources()) diff --git a/examples/snippets/clients/parsing_tool_results.py b/examples/snippets/clients/parsing_tool_results.py new file mode 100644 index 0000000..f9aade4 --- /dev/null +++ b/examples/snippets/clients/parsing_tool_results.py @@ -0,0 +1,62 @@ +"""examples/snippets/clients/parsing_tool_results.py""" + +import asyncio + +import mcp_types as types + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + + +async def parse_tool_results(): + """Demonstrates how to parse different types of content in CallToolResult.""" + server_params = StdioServerParameters(command="python", args=["path/to/mcp_server.py"]) + + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + # Example 1: Parsing text content + result = await session.call_tool("get_data", {"format": "text"}) + for content in result.content: + if isinstance(content, types.TextContent): + print(f"Text: {content.text}") + + # Example 2: Parsing structured content from JSON tools + result = await session.call_tool("get_user", {"id": "123"}) + if hasattr(result, "structured_content") and result.structured_content: + # Access structured data directly + user_data = result.structured_content + print(f"User: {user_data.get('name')}, Age: {user_data.get('age')}") + + # Example 3: Parsing embedded resources + result = await session.call_tool("read_config", {}) + for content in result.content: + if isinstance(content, types.EmbeddedResource): + resource = content.resource + if isinstance(resource, types.TextResourceContents): + print(f"Config from {resource.uri}: {resource.text}") + else: + print(f"Binary data from {resource.uri}") + + # Example 4: Parsing image content + result = await session.call_tool("generate_chart", {"data": [1, 2, 3]}) + for content in result.content: + if isinstance(content, types.ImageContent): + print(f"Image ({content.mime_type}): {len(content.data)} bytes") + + # Example 5: Handling errors + result = await session.call_tool("failing_tool", {}) + if result.is_error: + print("Tool execution failed!") + for content in result.content: + if isinstance(content, types.TextContent): + print(f"Error: {content.text}") + + +async def main(): + await parse_tool_results() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/snippets/clients/stdio_client.py b/examples/snippets/clients/stdio_client.py new file mode 100644 index 0000000..6fff083 --- /dev/null +++ b/examples/snippets/clients/stdio_client.py @@ -0,0 +1,82 @@ +"""cd to the `examples/snippets/clients` directory and run: +uv run client +""" + +import asyncio +import os + +import mcp_types as types + +from mcp import ClientSession, StdioServerParameters +from mcp.client.context import ClientRequestContext +from mcp.client.stdio import stdio_client + +# Create server parameters for stdio connection +server_params = StdioServerParameters( + command="uv", # Using uv to run the server + args=["run", "server", "mcpserver_quickstart", "stdio"], # We're already in snippets dir + env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, +) + + +# Optional: create a sampling callback +async def handle_sampling_message( + context: ClientRequestContext, params: types.CreateMessageRequestParams +) -> types.CreateMessageResult: + print(f"Sampling request: {params.messages}") + return types.CreateMessageResult( + role="assistant", + content=types.TextContent( + type="text", + text="Hello, world! from model", + ), + model="gpt-3.5-turbo", + stop_reason="endTurn", + ) + + +async def run(): + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write, sampling_callback=handle_sampling_message) as session: + # Initialize the connection + await session.initialize() + + # List available prompts + prompts = await session.list_prompts() + print(f"Available prompts: {[p.name for p in prompts.prompts]}") + + # Get a prompt (greet_user prompt from mcpserver_quickstart) + if prompts.prompts: + prompt = await session.get_prompt("greet_user", arguments={"name": "Alice", "style": "friendly"}) + print(f"Prompt result: {prompt.messages[0].content}") + + # List available resources + resources = await session.list_resources() + print(f"Available resources: {[r.uri for r in resources.resources]}") + + # List available tools + tools = await session.list_tools() + print(f"Available tools: {[t.name for t in tools.tools]}") + + # Read a resource (greeting resource from mcpserver_quickstart) + resource_content = await session.read_resource("greeting://World") + content_block = resource_content.contents[0] + if isinstance(content_block, types.TextResourceContents): + print(f"Resource content: {content_block.text}") + + # Call a tool (add tool from mcpserver_quickstart) + result = await session.call_tool("add", arguments={"a": 5, "b": 3}) + result_unstructured = result.content[0] + if isinstance(result_unstructured, types.TextContent): + print(f"Tool result: {result_unstructured.text}") + result_structured = result.structured_content + print(f"Structured tool result: {result_structured}") + + +def main(): + """Entry point for the client script.""" + asyncio.run(run()) + + +if __name__ == "__main__": + main() diff --git a/examples/snippets/clients/streamable_basic.py b/examples/snippets/clients/streamable_basic.py new file mode 100644 index 0000000..43bb639 --- /dev/null +++ b/examples/snippets/clients/streamable_basic.py @@ -0,0 +1,24 @@ +"""Run from the repository root: +uv run examples/snippets/clients/streamable_basic.py +""" + +import asyncio + +from mcp import ClientSession +from mcp.client.streamable_http import streamable_http_client + + +async def main(): + # Connect to a streamable HTTP server + async with streamable_http_client("http://localhost:8000/mcp") as (read_stream, write_stream): + # Create a session using the client streams + async with ClientSession(read_stream, write_stream) as session: + # Initialize the connection + await session.initialize() + # List available tools + tools = await session.list_tools() + print(f"Available tools: {[tool.name for tool in tools.tools]}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/snippets/clients/url_elicitation_client.py b/examples/snippets/clients/url_elicitation_client.py new file mode 100644 index 0000000..de962eb --- /dev/null +++ b/examples/snippets/clients/url_elicitation_client.py @@ -0,0 +1,318 @@ +"""URL Elicitation Client Example. + +Demonstrates how clients handle URL elicitation requests from servers. +This is the Python equivalent of TypeScript SDK's elicitationUrlExample.ts, +focused on URL elicitation patterns without OAuth complexity. + +Features demonstrated: +1. Client elicitation capability declaration +2. Handling elicitation requests from servers via callback +3. Catching UrlElicitationRequiredError from tool calls +4. Browser interaction with security warnings +5. Interactive CLI for testing + +Run with: + cd examples/snippets + uv run elicitation-client + +Requires a server with URL elicitation tools running. Start the elicitation +server first: + uv run server elicitation sse +""" + +from __future__ import annotations + +import asyncio +import json +import webbrowser +from typing import Any +from urllib.parse import urlparse + +import mcp_types as types +from mcp_types import URL_ELICITATION_REQUIRED + +from mcp import ClientSession +from mcp.client.context import ClientRequestContext +from mcp.client.sse import sse_client +from mcp.shared.exceptions import MCPError, UrlElicitationRequiredError + + +async def handle_elicitation( + context: ClientRequestContext, + params: types.ElicitRequestParams, +) -> types.ElicitResult | types.ErrorData: + """Handle elicitation requests from the server. + + This callback is invoked when the server sends an elicitation/request. + For URL mode, we prompt the user and optionally open their browser. + """ + if params.mode == "url": + return await handle_url_elicitation(params) + else: + # We only support URL mode in this example + return types.ErrorData( + code=types.INVALID_REQUEST, + message=f"Unsupported elicitation mode: {params.mode}", + ) + + +ALLOWED_SCHEMES = {"http", "https"} + + +async def handle_url_elicitation( + params: types.ElicitRequestParams, +) -> types.ElicitResult: + """Handle URL mode elicitation - show security warning and optionally open browser. + + This function demonstrates the security-conscious approach to URL elicitation: + 1. Validate the URL scheme before prompting the user + 2. Display the full URL and domain for user inspection + 3. Show the server's reason for requesting this interaction + 4. Require explicit user consent before opening any URL + """ + # Extract URL parameters - these are available on URL mode requests + url = getattr(params, "url", None) + elicitation_id = getattr(params, "elicitationId", None) + message = params.message + + if not url: + print("Error: No URL provided in elicitation request") + return types.ElicitResult(action="cancel") + + # Reject dangerous URL schemes before prompting the user + parsed = urlparse(str(url)) + if parsed.scheme.lower() not in ALLOWED_SCHEMES: + print(f"\nRejecting URL with disallowed scheme '{parsed.scheme}': {url}") + return types.ElicitResult(action="decline") + + # Extract domain for security display + domain = extract_domain(url) + + # Security warning - always show the user what they're being asked to do + print("\n" + "=" * 60) + print("SECURITY WARNING: External URL Request") + print("=" * 60) + print("\nThe server is requesting you to open an external URL.") + print(f"\n Domain: {domain}") + print(f" Full URL: {url}") + print("\n Server's reason:") + print(f" {message}") + print(f"\n Elicitation ID: {elicitation_id}") + print("\n" + "-" * 60) + + # Get explicit user consent + try: + response = input("\nOpen this URL in your browser? (y/n): ").strip().lower() + except EOFError: + return types.ElicitResult(action="cancel") + + if response in ("n", "no"): + print("URL navigation declined.") + return types.ElicitResult(action="decline") + elif response not in ("y", "yes"): + print("Invalid response. Cancelling.") + return types.ElicitResult(action="cancel") + + # Open the browser + print(f"\nOpening browser to: {url}") + try: + webbrowser.open(url) + except Exception as e: + print(f"Failed to open browser: {e}") + print(f"Please manually open: {url}") + + print("Waiting for you to complete the interaction in your browser...") + print("(The server will continue once you've finished)") + + return types.ElicitResult(action="accept") + + +def extract_domain(url: str) -> str: + """Extract domain from URL for security display.""" + try: + return urlparse(url).netloc + except Exception: + return "unknown" + + +async def call_tool_with_error_handling( + session: ClientSession, + tool_name: str, + arguments: dict[str, Any], +) -> types.CallToolResult | None: + """Call a tool, handling UrlElicitationRequiredError if raised. + + When a server tool needs URL elicitation before it can proceed, + it can either: + 1. Send an elicitation request directly (handled by elicitation_callback) + 2. Return an error with code -32042 (URL_ELICITATION_REQUIRED) + + This function demonstrates handling case 2 - catching the error + and processing the required URL elicitations. + """ + try: + result = await session.call_tool(tool_name, arguments) + + # Check if the tool returned an error in the result + if result.is_error: + print(f"Tool returned error: {result.content}") + return None + + return result + + except MCPError as e: + # Check if this is a URL elicitation required error + if e.code == URL_ELICITATION_REQUIRED: + print("\n[Tool requires URL elicitation to proceed]") + + # Convert to typed error to access elicitations + url_error = UrlElicitationRequiredError.from_error(e.error) + + # Process each required elicitation + for elicitation in url_error.elicitations: + await handle_url_elicitation(elicitation) + + return None + else: + # Re-raise other MCP errors + print(f"MCP Error: {e.error.message} (code: {e.error.code})") + return None + + +def print_help() -> None: + """Print available commands.""" + print("\nAvailable commands:") + print(" list-tools - List available tools") + print(" call [json-args] - Call a tool with optional JSON arguments") + print(" secure-payment - Test URL elicitation via ctx.elicit_url()") + print(" connect-service - Test URL elicitation via UrlElicitationRequiredError") + print(" help - Show this help") + print(" quit - Exit the program") + + +def print_tool_result(result: types.CallToolResult | None) -> None: + """Print a tool call result.""" + if not result: + return + print("\nTool result:") + for content in result.content: + if isinstance(content, types.TextContent): + print(f" {content.text}") + else: + print(f" [{content.type}]") + + +async def handle_list_tools(session: ClientSession) -> None: + """Handle the list-tools command.""" + tools = await session.list_tools() + if tools.tools: + print("\nAvailable tools:") + for tool in tools.tools: + print(f" - {tool.name}: {tool.description or 'No description'}") + else: + print("No tools available") + + +async def handle_call_command(session: ClientSession, command: str) -> None: + """Handle the call command.""" + parts = command.split(maxsplit=2) + if len(parts) < 2: + print("Usage: call [json-args]") + return + + tool_name = parts[1] + args: dict[str, Any] = {} + if len(parts) > 2: + try: + args = json.loads(parts[2]) + except json.JSONDecodeError as e: + print(f"Invalid JSON arguments: {e}") + return + + print(f"\nCalling tool '{tool_name}' with args: {args}") + result = await call_tool_with_error_handling(session, tool_name, args) + print_tool_result(result) + + +async def process_command(session: ClientSession, command: str) -> bool: + """Process a single command. Returns False if should exit.""" + if command in {"quit", "exit"}: + print("Goodbye!") + return False + + if command == "help": + print_help() + elif command == "list-tools": + await handle_list_tools(session) + elif command.startswith("call "): + await handle_call_command(session, command) + elif command == "secure-payment": + print("\nTesting secure_payment tool (uses ctx.elicit_url())...") + result = await call_tool_with_error_handling(session, "secure_payment", {"amount": 99.99}) + print_tool_result(result) + elif command == "connect-service": + print("\nTesting connect_service tool (raises UrlElicitationRequiredError)...") + result = await call_tool_with_error_handling(session, "connect_service", {"service_name": "github"}) + print_tool_result(result) + else: + print(f"Unknown command: {command}") + print("Type 'help' for available commands.") + + return True + + +async def run_command_loop(session: ClientSession) -> None: + """Run the interactive command loop.""" + while True: + try: + command = input("> ").strip() + except EOFError: + break + except KeyboardInterrupt: + print("\n") + break + + if not command: + continue + + if not await process_command(session, command): + break + + +async def main() -> None: + """Run the interactive URL elicitation client.""" + server_url = "http://localhost:8000/sse" + + print("=" * 60) + print("URL Elicitation Client Example") + print("=" * 60) + print(f"\nConnecting to: {server_url}") + print("(Start server with: cd examples/snippets && uv run server elicitation sse)") + + try: + async with sse_client(server_url) as (read, write): + async with ClientSession( + read, + write, + elicitation_callback=handle_elicitation, + ) as session: + await session.initialize() + print("\nConnected! Type 'help' for available commands.\n") + await run_command_loop(session) + + except ConnectionRefusedError: + print(f"\nError: Could not connect to {server_url}") + print("Make sure the elicitation server is running:") + print(" cd examples/snippets && uv run server elicitation sse") + except Exception as e: + print(f"\nError: {e}") + raise + + +def run() -> None: + """Entry point for the client script.""" + asyncio.run(main()) + + +if __name__ == "__main__": + run() diff --git a/examples/snippets/pyproject.toml b/examples/snippets/pyproject.toml new file mode 100644 index 0000000..735d522 --- /dev/null +++ b/examples/snippets/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "mcp-snippets" +version = "0.1.0" +description = "MCP Example Snippets" +requires-python = ">=3.10" +dependencies = [ + "mcp", +] + +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["servers", "clients"] + +[project.scripts] +server = "servers:run_server" +client = "clients.stdio_client:main" +completion-client = "clients.completion_client:main" +direct-execution-server = "servers.direct_execution:main" +display-utilities-client = "clients.display_utilities:main" +oauth-client = "clients.oauth_client:run" +identity-assertion-client = "clients.identity_assertion_client:run" +elicitation-client = "clients.url_elicitation_client:run" diff --git a/examples/snippets/servers/__init__.py b/examples/snippets/servers/__init__.py new file mode 100644 index 0000000..f132f87 --- /dev/null +++ b/examples/snippets/servers/__init__.py @@ -0,0 +1,37 @@ +"""MCP Snippets. + +This package contains simple examples of MCP server features. +Each server demonstrates a single feature and can be run as a standalone server. + +To run a server, use the command: + uv run server basic_tool sse +""" + +import importlib +import sys +from typing import Literal, cast + + +def run_server(): + """Run a server by name with optional transport. + + Usage: server [transport] + Example: server basic_tool sse + """ + if len(sys.argv) < 2: + print("Usage: server [transport]") + print("Available servers: basic_tool, basic_resource, basic_prompt, tool_progress,") + print(" sampling, elicitation, completion, notifications,") + print(" mcpserver_quickstart, structured_output, images") + print("Available transports: stdio (default), sse, streamable-http") + sys.exit(1) + + server_name = sys.argv[1] + transport = sys.argv[2] if len(sys.argv) > 2 else "stdio" + + try: + module = importlib.import_module(f".{server_name}", package=__name__) + module.mcp.run(cast(Literal["stdio", "sse", "streamable-http"], transport)) + except ImportError: + print(f"Error: Server '{server_name}' not found") + sys.exit(1) diff --git a/examples/snippets/servers/basic_prompt.py b/examples/snippets/servers/basic_prompt.py new file mode 100644 index 0000000..d2eee97 --- /dev/null +++ b/examples/snippets/servers/basic_prompt.py @@ -0,0 +1,18 @@ +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.prompts import base + +mcp = MCPServer(name="Prompt Example") + + +@mcp.prompt(title="Code Review") +def review_code(code: str) -> str: + return f"Please review this code:\n\n{code}" + + +@mcp.prompt(title="Debug Assistant") +def debug_error(error: str) -> list[base.Message]: + return [ + base.UserMessage("I'm seeing this error:"), + base.UserMessage(error), + base.AssistantMessage("I'll help debug that. What have you tried so far?"), + ] diff --git a/examples/snippets/servers/basic_resource.py b/examples/snippets/servers/basic_resource.py new file mode 100644 index 0000000..38d96b4 --- /dev/null +++ b/examples/snippets/servers/basic_resource.py @@ -0,0 +1,20 @@ +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer(name="Resource Example") + + +@mcp.resource("file://documents/{name}") +def read_document(name: str) -> str: + """Read a document by name.""" + # This would normally read from disk + return f"Content of {name}" + + +@mcp.resource("config://settings") +def get_settings() -> str: + """Get application settings.""" + return """{ + "theme": "dark", + "language": "en", + "debug": false +}""" diff --git a/examples/snippets/servers/basic_tool.py b/examples/snippets/servers/basic_tool.py new file mode 100644 index 0000000..7648024 --- /dev/null +++ b/examples/snippets/servers/basic_tool.py @@ -0,0 +1,16 @@ +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer(name="Tool Example") + + +@mcp.tool() +def sum(a: int, b: int) -> int: + """Add two numbers together.""" + return a + b + + +@mcp.tool() +def get_weather(city: str, unit: str = "celsius") -> str: + """Get weather for a city.""" + # This would normally call a weather API + return f"Weather in {city}: 22degrees{unit[0].upper()}" diff --git a/examples/snippets/servers/completion.py b/examples/snippets/servers/completion.py new file mode 100644 index 0000000..7fc2f20 --- /dev/null +++ b/examples/snippets/servers/completion.py @@ -0,0 +1,50 @@ +from mcp_types import ( + Completion, + CompletionArgument, + CompletionContext, + PromptReference, + ResourceTemplateReference, +) + +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer(name="Example") + + +@mcp.resource("github://repos/{owner}/{repo}") +def github_repo(owner: str, repo: str) -> str: + """GitHub repository resource.""" + return f"Repository: {owner}/{repo}" + + +@mcp.prompt(description="Code review prompt") +def review_code(language: str, code: str) -> str: + """Generate a code review.""" + return f"Review this {language} code:\n{code}" + + +@mcp.completion() +async def handle_completion( + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, +) -> Completion | None: + """Provide completions for prompts and resources.""" + + # Complete programming languages for the prompt + if isinstance(ref, PromptReference): + if ref.name == "review_code" and argument.name == "language": + languages = ["python", "javascript", "typescript", "go", "rust"] + return Completion( + values=[lang for lang in languages if lang.startswith(argument.value)], + has_more=False, + ) + + # Complete repository names for GitHub resources + if isinstance(ref, ResourceTemplateReference): + if ref.uri == "github://repos/{owner}/{repo}" and argument.name == "repo": + if context and context.arguments and context.arguments.get("owner") == "modelcontextprotocol": + repos = ["python-sdk", "typescript-sdk", "specification"] + return Completion(values=repos, has_more=False) + + return None diff --git a/examples/snippets/servers/direct_call_tool_result.py b/examples/snippets/servers/direct_call_tool_result.py new file mode 100644 index 0000000..f303533 --- /dev/null +++ b/examples/snippets/servers/direct_call_tool_result.py @@ -0,0 +1,42 @@ +"""Example showing direct CallToolResult return for advanced control.""" + +from typing import Annotated + +from mcp_types import CallToolResult, TextContent +from pydantic import BaseModel + +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("CallToolResult Example") + + +class ValidationModel(BaseModel): + """Model for validating structured output.""" + + status: str + data: dict[str, int] + + +@mcp.tool() +def advanced_tool() -> CallToolResult: + """Return CallToolResult directly for full control including _meta field.""" + return CallToolResult( + content=[TextContent(type="text", text="Response visible to the model")], + _meta={"hidden": "data for client applications only"}, + ) + + +@mcp.tool() +def validated_tool() -> Annotated[CallToolResult, ValidationModel]: + """Return CallToolResult with structured output validation.""" + return CallToolResult( + content=[TextContent(type="text", text="Validated response")], + structured_content={"status": "success", "data": {"result": 42}}, + _meta={"internal": "metadata"}, + ) + + +@mcp.tool() +def empty_result_tool() -> CallToolResult: + """For empty results, return CallToolResult with empty content.""" + return CallToolResult(content=[]) diff --git a/examples/snippets/servers/direct_execution.py b/examples/snippets/servers/direct_execution.py new file mode 100644 index 0000000..acf7151 --- /dev/null +++ b/examples/snippets/servers/direct_execution.py @@ -0,0 +1,27 @@ +"""Example showing direct execution of an MCP server. + +This is the simplest way to run an MCP server directly. +cd to the `examples/snippets` directory and run: + uv run direct-execution-server + or + python servers/direct_execution.py +""" + +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("My App") + + +@mcp.tool() +def hello(name: str = "World") -> str: + """Say hello to someone.""" + return f"Hello, {name}!" + + +def main(): + """Entry point for the direct execution server.""" + mcp.run() + + +if __name__ == "__main__": + main() diff --git a/examples/snippets/servers/elicitation.py b/examples/snippets/servers/elicitation.py new file mode 100644 index 0000000..97e847b --- /dev/null +++ b/examples/snippets/servers/elicitation.py @@ -0,0 +1,98 @@ +"""Elicitation examples demonstrating form and URL mode elicitation. + +Form mode elicitation collects structured, non-sensitive data through a schema. +URL mode elicitation directs users to external URLs for sensitive operations +like OAuth flows, credential collection, or payment processing. +""" + +import uuid + +from mcp_types import ElicitRequestURLParams +from pydantic import BaseModel, Field + +from mcp.server.mcpserver import Context, MCPServer +from mcp.shared.exceptions import UrlElicitationRequiredError + +mcp = MCPServer(name="Elicitation Example") + + +class BookingPreferences(BaseModel): + """Schema for collecting user preferences.""" + + checkAlternative: bool = Field(description="Would you like to check another date?") + alternativeDate: str = Field( + default="2024-12-26", + description="Alternative date (YYYY-MM-DD)", + ) + + +@mcp.tool() +async def book_table(date: str, time: str, party_size: int, ctx: Context) -> str: + """Book a table with date availability check. + + This demonstrates form mode elicitation for collecting non-sensitive user input. + """ + # Check if date is available + if date == "2024-12-25": + # Date unavailable - ask user for alternative + result = await ctx.elicit( + message=(f"No tables available for {party_size} on {date}. Would you like to try another date?"), + schema=BookingPreferences, + ) + + if result.action == "accept" and result.data: + if result.data.checkAlternative: + return f"[SUCCESS] Booked for {result.data.alternativeDate}" + return "[CANCELLED] No booking made" + return "[CANCELLED] Booking cancelled" + + # Date available + return f"[SUCCESS] Booked for {date} at {time}" + + +@mcp.tool() +async def secure_payment(amount: float, ctx: Context) -> str: + """Process a secure payment requiring URL confirmation. + + This demonstrates URL mode elicitation using ctx.elicit_url() for + operations that require out-of-band user interaction. + """ + elicitation_id = str(uuid.uuid4()) + + result = await ctx.elicit_url( + message=f"Please confirm payment of ${amount:.2f}", + url=f"https://payments.example.com/confirm?amount={amount}&id={elicitation_id}", + elicitation_id=elicitation_id, + ) + + if result.action == "accept": + # In a real app, the payment confirmation would happen out-of-band + # and you'd verify the payment status from your backend + return f"Payment of ${amount:.2f} initiated - check your browser to complete" + elif result.action == "decline": + return "Payment declined by user" + return "Payment cancelled" + + +@mcp.tool() +async def connect_service(service_name: str, ctx: Context) -> str: + """Connect to a third-party service requiring OAuth authorization. + + This demonstrates the "throw error" pattern using UrlElicitationRequiredError. + Use this pattern when the tool cannot proceed without user authorization. + """ + elicitation_id = str(uuid.uuid4()) + + # Raise UrlElicitationRequiredError to signal that the client must complete + # a URL elicitation before this request can be processed. + # The MCP framework will convert this to a -32042 error response. + raise UrlElicitationRequiredError( + [ + ElicitRequestURLParams( + mode="url", + message=f"Authorization required to connect to {service_name}", + url=f"https://{service_name}.example.com/oauth/authorize?elicit={elicitation_id}", + elicitation_id=elicitation_id, + ) + ] + ) diff --git a/examples/snippets/servers/identity_assertion_server.py b/examples/snippets/servers/identity_assertion_server.py new file mode 100644 index 0000000..9406111 --- /dev/null +++ b/examples/snippets/servers/identity_assertion_server.py @@ -0,0 +1,103 @@ +"""Authorization-server side of SEP-990 (enterprise IdP policy controls). + +An authorization server enables the Identity Assertion Authorization Grant by setting +`identity_assertion_enabled=True` and implementing `exchange_identity_assertion` on its provider. +The client presents the IdP-issued ID-JAG using the RFC 7523 jwt-bearer grant; the provider +validates the assertion and mints an MCP access token. + +Validating the ID-JAG is the provider's responsibility and is only stubbed here. A real +implementation MUST, per RFC 7523 §3 and SEP-990 §5.1: + +- verify the JWT signature, `iss`, and `exp`, and that `typ` is `oauth-id-jag+jwt`; +- require `aud` to identify this authorization server; +- require the ID-JAG's `client_id` claim to match the authenticated client; +- audience-restrict the issued token to the resource named in the ID-JAG's `resource` claim + (NOT the client-supplied `params.resource`); +- derive the granted scopes from the ID-JAG and policy. + +`_decode_and_validate_id_jag` below raises `NotImplementedError` so this snippet fails closed and +forces a real implementation. Wire the returned routes into a Starlette app with +`create_auth_routes(..., identity_assertion_enabled=True)`, or set +`AuthSettings(identity_assertion_enabled=True)` with `MCPServer`/`Server`. +""" + +import secrets +import time +from dataclasses import dataclass + +from mcp.server.auth.provider import ( + AccessToken, + AuthorizationCode, + IdentityAssertionParams, + OAuthAuthorizationServerProvider, + RefreshToken, +) +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken + + +@dataclass +class IdJagClaims: + """The trusted claims extracted from a validated ID-JAG.""" + + subject: str # the end user the ID-JAG was issued for + client_id: str # the ID-JAG `client_id` claim; §5.1 requires it to match the authenticated client + resource: str # the MCP server the issued token must be audience-restricted to + scopes: list[str] + + +class IdentityAssertionProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]): + """Authorization-server provider that accepts an ID-JAG via the RFC 7523 jwt-bearer grant.""" + + def __init__(self) -> None: + self.access_tokens: dict[str, AccessToken] = {} + # SEP-990 clients are pre-registered out of band (DCR refuses the grant) and must be + # confidential. `get_client` must return them, or the token endpoint 401s before the + # exchange runs. Real deployments load these from their own store. + self.clients: dict[str, OAuthClientInformationFull] = { + "enterprise-mcp-client": OAuthClientInformationFull( + client_id="enterprise-mcp-client", + client_secret="enterprise-mcp-secret", + redirect_uris=None, + grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"], + token_endpoint_auth_method="client_secret_post", + ) + } + + async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: + return self.clients.get(client_id) + + async def exchange_identity_assertion( + self, client: OAuthClientInformationFull, params: IdentityAssertionParams + ) -> OAuthToken: + claims = self._decode_and_validate_id_jag(params.assertion, client) + + access_token = f"access_{secrets.token_hex(16)}" + self.access_tokens[access_token] = AccessToken( + token=access_token, + client_id=claims.client_id, + scopes=claims.scopes, + expires_at=int(time.time()) + 3600, + # Bind to the resource from the validated ID-JAG, not the client-controlled request. + resource=claims.resource, + subject=claims.subject, + ) + # No refresh token: SEP-990 relies on the IdP re-issuing ID-JAGs to control session lifetime. + return OAuthToken( + access_token=access_token, + token_type="Bearer", + expires_in=3600, + scope=" ".join(claims.scopes), + ) + + def _decode_and_validate_id_jag(self, assertion: str, client: OAuthClientInformationFull) -> IdJagClaims: + """Verify the ID-JAG and return its trusted claims, or reject the request. + + Replace this stub with real RFC 7523 §3 / SEP-990 §5.1 validation. It fails closed - it + raises rather than trusting the assertion - so a copy of this example cannot accidentally + accept unverified tokens. RFC 7523 §3.1 / RFC 6749 §5.2 specify `invalid_grant` for a + rejected assertion. + """ + raise NotImplementedError("Validate the ID-JAG (signature, iss/aud/exp/typ, client_id, resource)") + + async def load_access_token(self, token: str) -> AccessToken | None: + return self.access_tokens.get(token) diff --git a/examples/snippets/servers/images.py b/examples/snippets/servers/images.py new file mode 100644 index 0000000..b30c9a8 --- /dev/null +++ b/examples/snippets/servers/images.py @@ -0,0 +1,15 @@ +"""Example showing image handling with MCPServer.""" + +from PIL import Image as PILImage + +from mcp.server.mcpserver import Image, MCPServer + +mcp = MCPServer("Image Example") + + +@mcp.tool() +def create_thumbnail(image_path: str) -> Image: + """Create a thumbnail from an image""" + img = PILImage.open(image_path) + img.thumbnail((100, 100)) + return Image(data=img.tobytes(), format="png") diff --git a/examples/snippets/servers/lifespan_example.py b/examples/snippets/servers/lifespan_example.py new file mode 100644 index 0000000..f290d31 --- /dev/null +++ b/examples/snippets/servers/lifespan_example.py @@ -0,0 +1,56 @@ +"""Example showing lifespan support for startup/shutdown with strong typing.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from mcp.server.mcpserver import Context, MCPServer + + +# Mock database class for example +class Database: + """Mock database class for example.""" + + @classmethod + async def connect(cls) -> "Database": + """Connect to database.""" + return cls() + + async def disconnect(self) -> None: + """Disconnect from database.""" + pass + + def query(self) -> str: + """Execute a query.""" + return "Query result" + + +@dataclass +class AppContext: + """Application context with typed dependencies.""" + + db: Database + + +@asynccontextmanager +async def app_lifespan(server: MCPServer) -> AsyncIterator[AppContext]: + """Manage application lifecycle with type-safe context.""" + # Initialize on startup + db = await Database.connect() + try: + yield AppContext(db=db) + finally: + # Cleanup on shutdown + await db.disconnect() + + +# Pass lifespan to server +mcp = MCPServer("My App", lifespan=app_lifespan) + + +# Access type-safe lifespan context in tools +@mcp.tool() +def query_db(ctx: Context[AppContext]) -> str: + """Tool that uses initialized resources.""" + db = ctx.request_context.lifespan_context.db + return db.query() diff --git a/examples/snippets/servers/lowlevel/__init__.py b/examples/snippets/servers/lowlevel/__init__.py new file mode 100644 index 0000000..c6ae62d --- /dev/null +++ b/examples/snippets/servers/lowlevel/__init__.py @@ -0,0 +1 @@ +"""Low-level server examples for MCP Python SDK.""" diff --git a/examples/snippets/servers/lowlevel/basic.py b/examples/snippets/servers/lowlevel/basic.py new file mode 100644 index 0000000..ff9b0a2 --- /dev/null +++ b/examples/snippets/servers/lowlevel/basic.py @@ -0,0 +1,64 @@ +"""Run from the repository root: +uv run examples/snippets/servers/lowlevel/basic.py +""" + +import asyncio + +import mcp_types as types + +import mcp.server.stdio +from mcp.server import Server, ServerRequestContext + + +async def handle_list_prompts( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListPromptsResult: + """List available prompts.""" + return types.ListPromptsResult( + prompts=[ + types.Prompt( + name="example-prompt", + description="An example prompt template", + arguments=[types.PromptArgument(name="arg1", description="Example argument", required=True)], + ) + ] + ) + + +async def handle_get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> types.GetPromptResult: + """Get a specific prompt by name.""" + if params.name != "example-prompt": + raise ValueError(f"Unknown prompt: {params.name}") + + arg1_value = (params.arguments or {}).get("arg1", "default") + + return types.GetPromptResult( + description="Example prompt", + messages=[ + types.PromptMessage( + role="user", + content=types.TextContent(type="text", text=f"Example prompt text with argument: {arg1_value}"), + ) + ], + ) + + +server = Server( + "example-server", + on_list_prompts=handle_list_prompts, + on_get_prompt=handle_get_prompt, +) + + +async def run(): + """Run the basic low-level server.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + server.create_initialization_options(), + ) + + +if __name__ == "__main__": + asyncio.run(run()) diff --git a/examples/snippets/servers/lowlevel/direct_call_tool_result.py b/examples/snippets/servers/lowlevel/direct_call_tool_result.py new file mode 100644 index 0000000..4d6607d --- /dev/null +++ b/examples/snippets/servers/lowlevel/direct_call_tool_result.py @@ -0,0 +1,63 @@ +"""Run from the repository root: +uv run examples/snippets/servers/lowlevel/direct_call_tool_result.py +""" + +import asyncio + +import mcp_types as types + +import mcp.server.stdio +from mcp.server import Server, ServerRequestContext + + +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + """List available tools.""" + return types.ListToolsResult( + tools=[ + types.Tool( + name="advanced_tool", + description="Tool with full control including _meta field", + input_schema={ + "type": "object", + "properties": {"message": {"type": "string"}}, + "required": ["message"], + }, + ) + ] + ) + + +async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + """Handle tool calls by returning CallToolResult directly.""" + if params.name == "advanced_tool": + message = (params.arguments or {}).get("message", "") + return types.CallToolResult( + content=[types.TextContent(type="text", text=f"Processed: {message}")], + structured_content={"result": "success", "message": message}, + _meta={"hidden": "data for client applications only"}, + ) + + raise ValueError(f"Unknown tool: {params.name}") + + +server = Server( + "example-server", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, +) + + +async def run(): + """Run the server.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + server.create_initialization_options(), + ) + + +if __name__ == "__main__": + asyncio.run(run()) diff --git a/examples/snippets/servers/lowlevel/lifespan.py b/examples/snippets/servers/lowlevel/lifespan.py new file mode 100644 index 0000000..46db9ec --- /dev/null +++ b/examples/snippets/servers/lowlevel/lifespan.py @@ -0,0 +1,102 @@ +"""Run from the repository root: +uv run examples/snippets/servers/lowlevel/lifespan.py +""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import TypedDict + +import mcp_types as types + +import mcp.server.stdio +from mcp.server import Server, ServerRequestContext + + +# Mock database class for example +class Database: + """Mock database class for example.""" + + @classmethod + async def connect(cls) -> "Database": + """Connect to database.""" + print("Database connected") + return cls() + + async def disconnect(self) -> None: + """Disconnect from database.""" + print("Database disconnected") + + async def query(self, query_str: str) -> list[dict[str, str]]: + """Execute a query.""" + # Simulate database query + return [{"id": "1", "name": "Example", "query": query_str}] + + +class AppContext(TypedDict): + db: Database + + +@asynccontextmanager +async def server_lifespan(_server: Server[AppContext]) -> AsyncIterator[AppContext]: + """Manage server startup and shutdown lifecycle.""" + db = await Database.connect() + try: + yield {"db": db} + finally: + await db.disconnect() + + +async def handle_list_tools( + ctx: ServerRequestContext[AppContext], params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + """List available tools.""" + return types.ListToolsResult( + tools=[ + types.Tool( + name="query_db", + description="Query the database", + input_schema={ + "type": "object", + "properties": {"query": {"type": "string", "description": "SQL query to execute"}}, + "required": ["query"], + }, + ) + ] + ) + + +async def handle_call_tool( + ctx: ServerRequestContext[AppContext], params: types.CallToolRequestParams +) -> types.CallToolResult: + """Handle database query tool call.""" + if params.name != "query_db": + raise ValueError(f"Unknown tool: {params.name}") + + db = ctx.lifespan_context["db"] + results = await db.query((params.arguments or {})["query"]) + + return types.CallToolResult(content=[types.TextContent(type="text", text=f"Query results: {results}")]) + + +server = Server( + "example-server", + lifespan=server_lifespan, + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, +) + + +async def run(): + """Run the server with lifespan management.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + server.create_initialization_options(), + ) + + +if __name__ == "__main__": + import asyncio + + asyncio.run(run()) diff --git a/examples/snippets/servers/lowlevel/structured_output.py b/examples/snippets/servers/lowlevel/structured_output.py new file mode 100644 index 0000000..84e411f --- /dev/null +++ b/examples/snippets/servers/lowlevel/structured_output.py @@ -0,0 +1,81 @@ +"""Run from the repository root: +uv run examples/snippets/servers/lowlevel/structured_output.py +""" + +import asyncio +import json + +import mcp_types as types + +import mcp.server.stdio +from mcp.server import Server, ServerRequestContext + + +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + """List available tools with structured output schemas.""" + return types.ListToolsResult( + tools=[ + types.Tool( + name="get_weather", + description="Get current weather for a city", + input_schema={ + "type": "object", + "properties": {"city": {"type": "string", "description": "City name"}}, + "required": ["city"], + }, + output_schema={ + "type": "object", + "properties": { + "temperature": {"type": "number", "description": "Temperature in Celsius"}, + "condition": {"type": "string", "description": "Weather condition"}, + "humidity": {"type": "number", "description": "Humidity percentage"}, + "city": {"type": "string", "description": "City name"}, + }, + "required": ["temperature", "condition", "humidity", "city"], + }, + ) + ] + ) + + +async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + """Handle tool calls with structured output.""" + if params.name == "get_weather": + city = (params.arguments or {})["city"] + + weather_data = { + "temperature": 22.5, + "condition": "partly cloudy", + "humidity": 65, + "city": city, + } + + return types.CallToolResult( + content=[types.TextContent(type="text", text=json.dumps(weather_data, indent=2))], + structured_content=weather_data, + ) + + raise ValueError(f"Unknown tool: {params.name}") + + +server = Server( + "example-server", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, +) + + +async def run(): + """Run the structured output server.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + server.create_initialization_options(), + ) + + +if __name__ == "__main__": + asyncio.run(run()) diff --git a/examples/snippets/servers/mcpserver_quickstart.py b/examples/snippets/servers/mcpserver_quickstart.py new file mode 100644 index 0000000..70a83a5 --- /dev/null +++ b/examples/snippets/servers/mcpserver_quickstart.py @@ -0,0 +1,42 @@ +"""MCPServer quickstart example. + +Run from the repository root: + uv run examples/snippets/servers/mcpserver_quickstart.py +""" + +from mcp.server.mcpserver import MCPServer + +# Create an MCP server +mcp = MCPServer("Demo") + + +# Add an addition tool +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers""" + return a + b + + +# Add a dynamic greeting resource +@mcp.resource("greeting://{name}") +def get_greeting(name: str) -> str: + """Get a personalized greeting""" + return f"Hello, {name}!" + + +# Add a prompt +@mcp.prompt() +def greet_user(name: str, style: str = "friendly") -> str: + """Generate a greeting prompt""" + styles = { + "friendly": "Please write a warm, friendly greeting", + "formal": "Please write a formal, professional greeting", + "casual": "Please write a casual, relaxed greeting", + } + + return f"{styles.get(style, styles['friendly'])} for someone named {name}." + + +# Run with streamable HTTP transport +if __name__ == "__main__": + mcp.run(transport="streamable-http", json_response=True) diff --git a/examples/snippets/servers/notifications.py b/examples/snippets/servers/notifications.py new file mode 100644 index 0000000..05c0fbf --- /dev/null +++ b/examples/snippets/servers/notifications.py @@ -0,0 +1,18 @@ +from mcp.server.mcpserver import Context, MCPServer + +mcp = MCPServer(name="Notifications Example") + + +@mcp.tool() +async def process_data(data: str, ctx: Context) -> str: + """Process data with logging.""" + # Different log levels + await ctx.debug(f"Debug: Processing '{data}'") # pyright: ignore[reportDeprecated] + await ctx.info("Info: Starting processing") # pyright: ignore[reportDeprecated] + await ctx.warning("Warning: This is experimental") # pyright: ignore[reportDeprecated] + await ctx.error("Error: (This is just a demo)") # pyright: ignore[reportDeprecated] + + # Notify about resource changes + await ctx.session.send_resource_list_changed() + + return f"Processed: {data}" diff --git a/examples/snippets/servers/oauth_server.py b/examples/snippets/servers/oauth_server.py new file mode 100644 index 0000000..962ef06 --- /dev/null +++ b/examples/snippets/servers/oauth_server.py @@ -0,0 +1,45 @@ +"""Run from the repository root: +uv run examples/snippets/servers/oauth_server.py +""" + +from pydantic import AnyHttpUrl + +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.server.auth.settings import AuthSettings +from mcp.server.mcpserver import MCPServer + + +class SimpleTokenVerifier(TokenVerifier): + """Simple token verifier for demonstration.""" + + async def verify_token(self, token: str) -> AccessToken | None: + pass # This is where you would implement actual token validation + + +# Create MCPServer instance as a Resource Server +mcp = MCPServer( + "Weather Service", + # Token verifier for authentication + token_verifier=SimpleTokenVerifier(), + # Auth settings for RFC 9728 Protected Resource Metadata + auth=AuthSettings( + issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL + resource_server_url=AnyHttpUrl("http://localhost:3001"), # This server's URL + required_scopes=["user"], + ), +) + + +@mcp.tool() +async def get_weather(city: str = "London") -> dict[str, str]: + """Get weather data for a city""" + return { + "city": city, + "temperature": "22", + "condition": "Partly cloudy", + "humidity": "65%", + } + + +if __name__ == "__main__": + mcp.run(transport="streamable-http", json_response=True) diff --git a/examples/snippets/servers/pagination_example.py b/examples/snippets/servers/pagination_example.py new file mode 100644 index 0000000..4f7435a --- /dev/null +++ b/examples/snippets/servers/pagination_example.py @@ -0,0 +1,36 @@ +"""Example of implementing pagination with the low-level MCP server.""" + +import mcp_types as types + +from mcp.server import Server, ServerRequestContext + +# Sample data to paginate +ITEMS = [f"Item {i}" for i in range(1, 101)] # 100 items + + +async def handle_list_resources( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListResourcesResult: + """List resources with pagination support.""" + page_size = 10 + + # Extract cursor from request params + cursor = params.cursor if params is not None else None + + # Parse cursor to get offset + start = 0 if cursor is None else int(cursor) + end = start + page_size + + # Get page of resources + page_items = [ + types.Resource(uri=f"resource://items/{item}", name=item, description=f"Description for {item}") + for item in ITEMS[start:end] + ] + + # Determine next cursor + next_cursor = str(end) if end < len(ITEMS) else None + + return types.ListResourcesResult(resources=page_items, next_cursor=next_cursor) + + +server = Server("paginated-server", on_list_resources=handle_list_resources) diff --git a/examples/snippets/servers/sampling.py b/examples/snippets/servers/sampling.py new file mode 100644 index 0000000..83ec506 --- /dev/null +++ b/examples/snippets/servers/sampling.py @@ -0,0 +1,26 @@ +from mcp_types import SamplingMessage, TextContent + +from mcp.server.mcpserver import Context, MCPServer + +mcp = MCPServer(name="Sampling Example") + + +@mcp.tool() +async def generate_poem(topic: str, ctx: Context) -> str: + """Generate a poem using LLM sampling.""" + prompt = f"Write a short poem about {topic}" + + result = await ctx.session.create_message( # pyright: ignore[reportDeprecated] + messages=[ + SamplingMessage( + role="user", + content=TextContent(type="text", text=prompt), + ) + ], + max_tokens=100, + ) + + # Since we're not passing tools param, result.content is single content + if result.content.type == "text": + return result.content.text + return str(result.content) diff --git a/examples/snippets/servers/streamable_config.py b/examples/snippets/servers/streamable_config.py new file mode 100644 index 0000000..622e670 --- /dev/null +++ b/examples/snippets/servers/streamable_config.py @@ -0,0 +1,28 @@ +"""Run from the repository root: +uv run examples/snippets/servers/streamable_config.py +""" + +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("StatelessServer") + + +# Add a simple tool to demonstrate the server +@mcp.tool() +def greet(name: str = "World") -> str: + """Greet someone by name.""" + return f"Hello, {name}!" + + +# Run server with streamable_http transport +# Transport-specific options (stateless_http, json_response) are passed to run() +if __name__ == "__main__": + # Stateless server with JSON responses (recommended) + mcp.run(transport="streamable-http", stateless_http=True, json_response=True) + + # Other configuration options: + # Stateless server with SSE streaming responses + # mcp.run(transport="streamable-http", stateless_http=True) + + # Stateful server with session persistence + # mcp.run(transport="streamable-http") diff --git a/examples/snippets/servers/streamable_http_basic_mounting.py b/examples/snippets/servers/streamable_http_basic_mounting.py new file mode 100644 index 0000000..9a53034 --- /dev/null +++ b/examples/snippets/servers/streamable_http_basic_mounting.py @@ -0,0 +1,38 @@ +"""Basic example showing how to mount StreamableHTTP server in Starlette. + +Run from the repository root: + uvicorn examples.snippets.servers.streamable_http_basic_mounting:app --reload +""" + +import contextlib + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server.mcpserver import MCPServer + +# Create MCP server +mcp = MCPServer("My App") + + +@mcp.tool() +def hello() -> str: + """A simple hello tool""" + return "Hello from MCP!" + + +# Create a lifespan context manager to run the session manager +@contextlib.asynccontextmanager +async def lifespan(app: Starlette): + async with mcp.session_manager.run(): + yield + + +# Mount the StreamableHTTP server to the existing ASGI server +# Transport-specific options are passed to streamable_http_app() +app = Starlette( + routes=[ + Mount("/", app=mcp.streamable_http_app(json_response=True)), + ], + lifespan=lifespan, +) diff --git a/examples/snippets/servers/streamable_http_host_mounting.py b/examples/snippets/servers/streamable_http_host_mounting.py new file mode 100644 index 0000000..2a41f74 --- /dev/null +++ b/examples/snippets/servers/streamable_http_host_mounting.py @@ -0,0 +1,38 @@ +"""Example showing how to mount StreamableHTTP server using Host-based routing. + +Run from the repository root: + uvicorn examples.snippets.servers.streamable_http_host_mounting:app --reload +""" + +import contextlib + +from starlette.applications import Starlette +from starlette.routing import Host + +from mcp.server.mcpserver import MCPServer + +# Create MCP server +mcp = MCPServer("MCP Host App") + + +@mcp.tool() +def domain_info() -> str: + """Get domain-specific information""" + return "This is served from mcp.acme.corp" + + +# Create a lifespan context manager to run the session manager +@contextlib.asynccontextmanager +async def lifespan(app: Starlette): + async with mcp.session_manager.run(): + yield + + +# Mount using Host-based routing +# Transport-specific options are passed to streamable_http_app() +app = Starlette( + routes=[ + Host("mcp.acme.corp", app=mcp.streamable_http_app(json_response=True)), + ], + lifespan=lifespan, +) diff --git a/examples/snippets/servers/streamable_http_multiple_servers.py b/examples/snippets/servers/streamable_http_multiple_servers.py new file mode 100644 index 0000000..71217bd --- /dev/null +++ b/examples/snippets/servers/streamable_http_multiple_servers.py @@ -0,0 +1,48 @@ +"""Example showing how to mount multiple StreamableHTTP servers with path configuration. + +Run from the repository root: + uvicorn examples.snippets.servers.streamable_http_multiple_servers:app --reload +""" + +import contextlib + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server.mcpserver import MCPServer + +# Create multiple MCP servers +api_mcp = MCPServer("API Server") +chat_mcp = MCPServer("Chat Server") + + +@api_mcp.tool() +def api_status() -> str: + """Get API status""" + return "API is running" + + +@chat_mcp.tool() +def send_message(message: str) -> str: + """Send a chat message""" + return f"Message sent: {message}" + + +# Create a combined lifespan to manage both session managers +@contextlib.asynccontextmanager +async def lifespan(app: Starlette): + async with contextlib.AsyncExitStack() as stack: + await stack.enter_async_context(api_mcp.session_manager.run()) + await stack.enter_async_context(chat_mcp.session_manager.run()) + yield + + +# Mount the servers with transport-specific options passed to streamable_http_app() +# streamable_http_path="/" means endpoints will be at /api and /chat instead of /api/mcp and /chat/mcp +app = Starlette( + routes=[ + Mount("/api", app=api_mcp.streamable_http_app(json_response=True, streamable_http_path="/")), + Mount("/chat", app=chat_mcp.streamable_http_app(json_response=True, streamable_http_path="/")), + ], + lifespan=lifespan, +) diff --git a/examples/snippets/servers/streamable_http_path_config.py b/examples/snippets/servers/streamable_http_path_config.py new file mode 100644 index 0000000..4c65ffd --- /dev/null +++ b/examples/snippets/servers/streamable_http_path_config.py @@ -0,0 +1,31 @@ +"""Example showing path configuration when mounting MCPServer. + +Run from the repository root: + uvicorn examples.snippets.servers.streamable_http_path_config:app --reload +""" + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server.mcpserver import MCPServer + +# Create a simple MCPServer server +mcp_at_root = MCPServer("My Server") + + +@mcp_at_root.tool() +def process_data(data: str) -> str: + """Process some data""" + return f"Processed: {data}" + + +# Mount at /process with streamable_http_path="/" so the endpoint is /process (not /process/mcp) +# Transport-specific options like json_response are passed to streamable_http_app() +app = Starlette( + routes=[ + Mount( + "/process", + app=mcp_at_root.streamable_http_app(json_response=True, streamable_http_path="/"), + ), + ] +) diff --git a/examples/snippets/servers/streamable_starlette_mount.py b/examples/snippets/servers/streamable_starlette_mount.py new file mode 100644 index 0000000..eb6f1b8 --- /dev/null +++ b/examples/snippets/servers/streamable_starlette_mount.py @@ -0,0 +1,53 @@ +"""Run from the repository root: +uvicorn examples.snippets.servers.streamable_starlette_mount:app --reload +""" + +import contextlib + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server.mcpserver import MCPServer + +# Create the Echo server +echo_mcp = MCPServer(name="EchoServer") + + +@echo_mcp.tool() +def echo(message: str) -> str: + """A simple echo tool""" + return f"Echo: {message}" + + +# Create the Math server +math_mcp = MCPServer(name="MathServer") + + +@math_mcp.tool() +def add_two(n: int) -> int: + """Tool to add two to the input""" + return n + 2 + + +# Create a combined lifespan to manage both session managers +@contextlib.asynccontextmanager +async def lifespan(app: Starlette): + async with contextlib.AsyncExitStack() as stack: + await stack.enter_async_context(echo_mcp.session_manager.run()) + await stack.enter_async_context(math_mcp.session_manager.run()) + yield + + +# Create the Starlette app and mount the MCP servers +app = Starlette( + routes=[ + Mount("/echo", echo_mcp.streamable_http_app(stateless_http=True, json_response=True)), + Mount("/math", math_mcp.streamable_http_app(stateless_http=True, json_response=True)), + ], + lifespan=lifespan, +) + +# Note: Clients connect to http://localhost:8000/echo/mcp and http://localhost:8000/math/mcp +# To mount at the root of each path (e.g., /echo instead of /echo/mcp): +# 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) diff --git a/examples/snippets/servers/structured_output.py b/examples/snippets/servers/structured_output.py new file mode 100644 index 0000000..bea7b22 --- /dev/null +++ b/examples/snippets/servers/structured_output.py @@ -0,0 +1,97 @@ +"""Example showing structured output with tools.""" + +from typing import TypedDict + +from pydantic import BaseModel, Field + +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("Structured Output Example") + + +# Using Pydantic models for rich structured data +class WeatherData(BaseModel): + """Weather information structure.""" + + temperature: float = Field(description="Temperature in Celsius") + humidity: float = Field(description="Humidity percentage") + condition: str + wind_speed: float + + +@mcp.tool() +def get_weather(city: str) -> WeatherData: + """Get weather for a city - returns structured data.""" + # Simulated weather data + return WeatherData( + temperature=22.5, + humidity=45.0, + condition="sunny", + wind_speed=5.2, + ) + + +# Using TypedDict for simpler structures +class LocationInfo(TypedDict): + latitude: float + longitude: float + name: str + + +@mcp.tool() +def get_location(address: str) -> LocationInfo: + """Get location coordinates""" + return LocationInfo(latitude=51.5074, longitude=-0.1278, name="London, UK") + + +# Using dict[str, Any] for flexible schemas +@mcp.tool() +def get_statistics(data_type: str) -> dict[str, float]: + """Get various statistics""" + return {"mean": 42.5, "median": 40.0, "std_dev": 5.2} + + +# Ordinary classes with type hints work for structured output +class UserProfile: + name: str + age: int + email: str | None = None + + def __init__(self, name: str, age: int, email: str | None = None): + self.name = name + self.age = age + self.email = email + + +@mcp.tool() +def get_user(user_id: str) -> UserProfile: + """Get user profile - returns structured data""" + return UserProfile(name="Alice", age=30, email="alice@example.com") + + +# Classes WITHOUT type hints cannot be used for structured output +class UntypedConfig: + def __init__(self, setting1, setting2): # type: ignore[reportMissingParameterType] + self.setting1 = setting1 + self.setting2 = setting2 + + +@mcp.tool() +def get_config() -> UntypedConfig: + """This returns unstructured output - no schema generated""" + return UntypedConfig("value1", "value2") + + +# Lists and other types are wrapped automatically +@mcp.tool() +def list_cities() -> list[str]: + """Get a list of cities""" + return ["London", "Paris", "Tokyo"] + # Returns: {"result": ["London", "Paris", "Tokyo"]} + + +@mcp.tool() +def get_temperature(city: str) -> float: + """Get temperature as a simple float""" + return 22.5 + # Returns: {"result": 22.5} diff --git a/examples/snippets/servers/tool_progress.py b/examples/snippets/servers/tool_progress.py new file mode 100644 index 0000000..7870341 --- /dev/null +++ b/examples/snippets/servers/tool_progress.py @@ -0,0 +1,20 @@ +from mcp.server.mcpserver import Context, MCPServer + +mcp = MCPServer(name="Progress Example") + + +@mcp.tool() +async def long_running_task(task_name: str, ctx: Context, steps: int = 5) -> str: + """Execute a task with progress updates.""" + await ctx.info(f"Starting: {task_name}") # pyright: ignore[reportDeprecated] + + for i in range(steps): + progress = (i + 1) / steps + await ctx.report_progress( + progress=progress, + total=1.0, + message=f"Step {i + 1}/{steps}", + ) + await ctx.debug(f"Completed step {i + 1}") # pyright: ignore[reportDeprecated] + + return f"Task '{task_name}' completed" diff --git a/examples/stories/README.md b/examples/stories/README.md new file mode 100644 index 0000000..ed8f1dd --- /dev/null +++ b/examples/stories/README.md @@ -0,0 +1,166 @@ +# Story examples + +One feature per folder. Each story is a small, self-verifying program: a +`server.py` (plus, where the wire contract is worth seeing by hand, a +`server_lowlevel.py`) and a `client.py` whose `main()` makes assertions and +exits non-zero on failure. The code you read here is the same code CI runs — +there is no separate test double. + +## Canonical shape + +Every `client.py` starts from this skeleton — copy it, then replace the body +with the story's assertions: + +```python +"""One line: what this client proves.""" + +from mcp.client import Client +from stories._harness import Target, run_client + + +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + ... # the story's assertions + + +if __name__ == "__main__": + run_client(main) +``` + +There are exactly two `main` shapes. A story that opens **one** connection +takes `main(target: Target, ...)`. A story that opens **more than one** sets +`multi_connection = true` in [`manifest.toml`](manifest.toml), takes +`main(targets: TargetFactory, ...)`, and calls `targets()` once per fresh +connection — a `Client` cannot be re-entered after exit. Nothing else changes +shape. + +Story files import from `stories._harness` only these names: `run_client`, +`target_from_args`, `Target`, `TargetFactory` — plus `AuthBuilder` for the +auth stories. Everything else a story uses comes from public `mcp.*` modules. + +The repetition this produces across stories is deliberate, not a refactor +waiting to happen: each `client.py` is a standalone, compiled doc page, so +when a public API changes, N red example files flag N doc pages. Don't pull +the `Client(target, mode=mode)` line (or anything around it) into a shared +helper. A story that can't be the canonical shape says why in its module +docstring's first line. + +## How to read a story + +Start with the story's README, then `server.py`, then `client.py`. Every +`client.py` exports `async def main(target, *, mode="auto")` — or +`main(targets, ...)` for the stories that open more than one connection — and +constructs the `Client` itself, so the body opens with the one line a client +example exists to teach: `async with Client(target, mode=mode) as client:`. +The `run_client(main)` call in the `__main__` block is only argv plumbing +(stdio vs `--http`, which `mode` to pass); it never hides how the client +connects. + +## Running a story + +From the repository root: + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.tools.client + +# HTTP, self-hosted — the client spawns the server on a real uvicorn socket on a +# port it owns, waits for it, runs, then terminates it. Nothing to background or kill. +uv run python -m stories.tools.client --http + +# the same self-hosted run against the story's lowlevel-API server variant +uv run python -m stories.tools.client --http --server server_lowlevel + +# HTTP against a server you run yourself +uv run python -m stories.tools.server --http --port 8000 # separate terminal +uv run python -m stories.tools.client --http http://127.0.0.1:8000/mcp +``` + +`--http` takes two forms. Bare `--http` is the canonical HTTP run — it is +complete on its own, and it is what every per-story README shows. `--http +` connects to a server you started yourself; the per-story READMEs spell +that out only where hosting is the lesson (the HTTP-hosting and auth stories). +`--server ` swaps in a sibling server module on stdio and on the +self-hosted `--http` run; with `--http ` you already picked the server +when you started it. The auth stories (`bearer_auth/`, `oauth/`, +`oauth_client_credentials/`) self-host on their fixed `:8000` instead of a +free port because their issuer/PRM metadata bake it in — `:8000` must be +free, and the run refuses to start (rather than silently testing whatever is +there) if it is not. + +The full matrix (every story × transport × era × server-variant) runs under +pytest: + +```bash +uv run --frozen pytest tests/examples/ # everything +uv run --frozen pytest tests/examples/ -k tools # one story +``` + +[`manifest.toml`](manifest.toml) declares each story's transports, era, status, +and variants; `tests/examples/` expands it. + +## Layout + +`_hosting.py` adapts a story's `build_server()` / `build_app()` to argv (stdio +vs `--http` serving); `_harness.py` is the client-side mirror — it picks the +`target` that `main()` connects to (a stdio subprocess by default, a self-hosted +HTTP subprocess under bare `--http`, your URL under `--http `). They +isolate the parts of the SDK's hosting surface +that are still moving — **don't copy them into your own project**; copy the +`server.py` / `client.py` bodies instead. `_shared/` holds an in-process OAuth +authorization server reused by the auth stories. + +## Stories + +The **status** column is the feature's standing in the protocol, from +[`manifest.toml`](manifest.toml): `current`, `legacy` (a 2025 handshake-era +mechanism with a 2026-era replacement), or `deprecated` (deprecated by +SEP-2577; functional through the deprecation window). Each non-`current` story's README +opens with a banner saying what replaces it. + +| story | what it shows | status | +|---|---|---| +| **— start here —** | | | +| [`tools`](tools/) | `@mcp.tool()`, schema inference, structured output, annotations | current | +| [`prompts`](prompts/) | `@mcp.prompt()`, list/get, argument completion | current | +| [`resources`](resources/) | `@mcp.resource()`, list/read, URI templates | current | +| [`lifespan`](lifespan/) | startup/shutdown lifespan, per-request state injection | current | +| [`dual_era`](dual_era/) | one server factory serving both protocol eras; era-neutral accessors | current | +| **— feature stories —** | | | +| [`streaming`](streaming/) | progress notifications, in-flight logging, cancellation | current | +| [`mrtr`](mrtr/) | `InputRequiredResult` round-trip: the `Client` auto-loop, a manual session-level loop, and the default `requestState` sealing (a tampered echo gets one frozen error) | current | +| [`legacy_elicitation`](legacy_elicitation/) | server pauses a tool to ask the user (form + url) via a push request | legacy | +| [`refund_desk`](refund_desk/) | resolver DI: `Annotated[T, Resolve(fn)]` params filled server-side, hidden from the input schema | current | +| [`sampling`](sampling/) | server asks the client's LLM mid-tool (push request) | deprecated | +| [`stickynotes`](stickynotes/) | capstone: tools mutate state → resources + `list_changed` + elicit guard | current | +| [`custom_methods`](custom_methods/) | vendor-prefixed JSON-RPC via `add_request_handler` / `send_request` | current | +| [`schema_validators`](schema_validators/) | tool input schema from pydantic / TypedDict / dataclass / dict | current | +| [`middleware`](middleware/) | server-side request/response middleware | current | +| [`parallel_calls`](parallel_calls/) | two clients rendezvous in one tool; per-call progress attribution | current | +| [`roots`](roots/) | client-declared roots, server reads them via `ctx` | deprecated | +| [`pagination`](pagination/) | manual cursor loop over list endpoints | current | +| [`error_handling`](error_handling/) | `is_error` results vs `MCPError`; `ToolError` | current | +| [`serve_one`](serve_one/) | building a `Connection` by hand and calling `serve_one` directly | current | +| **— HTTP hosting —** | | | +| [`stateless_legacy`](stateless_legacy/) | `streamable_http_app(stateless_http=True)`; the one-liner deploy | current | +| [`json_response`](json_response/) | `json_response=True` mode; raw 2026 POST envelope on the wire | current | +| [`legacy_routing`](legacy_routing/) | `classify_inbound_request()` era routing in front of a sessionful 1.x deploy | current | +| [`starlette_mount`](starlette_mount/) | mounting `streamable_http_app()` under a Starlette/FastAPI sub-path | current | +| [`sse_polling`](sse_polling/) | SEP-1699 `closeSSE()` + `Last-Event-ID` resume via `EventStore` | legacy | +| [`standalone_get`](standalone_get/) | server-initiated `list_changed` over the sessionful GET stream | legacy | +| [`subscriptions`](subscriptions/) | `subscriptions/listen` streams: `ctx.notify_*`, `SubscriptionBus`, `ListenHandler` | current | +| [`reconnect`](reconnect/) | explicit `discover()`, persist `DiscoverResult`, zero-RTT reconnect | current | +| [`bearer_auth`](bearer_auth/) | `TokenVerifier` + `AuthSettings` bearer gate, PRM metadata, `get_access_token()` | current | +| [`oauth`](oauth/) | full `authorization_code` grant against an in-process AS | current | +| [`oauth_client_credentials`](oauth_client_credentials/) | `client_credentials` grant; minimal in-process token endpoint | current | +| [`identity_assertion`](identity_assertion/) | SEP-990 enterprise IdP flow: present an ID-JAG under the `jwt-bearer` grant | current | +| **— deferred (README only) —** | | | +| [`caching`](caching/) | `CacheableResult` ttl/scope hints; client honouring | not yet implemented | +| [`tasks`](tasks/) | `io.modelcontextprotocol/tasks` extension | not yet implemented | +| [`apps`](apps/) | MCP Apps: `ui://` resource + `_meta.ui` | not yet implemented — [#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896) | +| [`skills`](skills/) | SEP-2640 skills extension | not yet implemented — [#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896) | +| [`events`](events/) | `io.modelcontextprotocol/events` extension | not yet implemented | + +The TypeScript SDK's `repl`, `client-quickstart`, and `server-quickstart` +examples are intentionally not ported (interactive / external network deps); +its `hono` example maps to `starlette_mount/`. diff --git a/examples/stories/__init__.py b/examples/stories/__init__.py new file mode 100644 index 0000000..6f4d605 --- /dev/null +++ b/examples/stories/__init__.py @@ -0,0 +1,6 @@ +"""Self-verifying example suite for the MCP Python SDK. + +Each story directory holds a ``server.py`` (and usually ``server_lowlevel.py``) +plus a ``client.py`` whose ``main(target, *, mode)`` runs against both. +``tests/examples/`` drives every story over an in-process matrix. +""" diff --git a/examples/stories/_harness.py b/examples/stories/_harness.py new file mode 100644 index 0000000..c7036ac --- /dev/null +++ b/examples/stories/_harness.py @@ -0,0 +1,203 @@ +"""Client-side scaffold for story examples. + +A story's ``client.py`` imports ``Target`` (or ``TargetFactory``) for its ``main`` +signature and calls ``run_client(main)`` from ``__main__``. The story owns the +``Client(target, mode=...)`` construction; this module only decides WHICH target +``__main__`` hands it. +""" + +from __future__ import annotations + +import socket +import sys +import traceback +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import AsyncExitStack, asynccontextmanager +from pathlib import Path +from typing import Any, TypeAlias +from urllib.parse import urlsplit + +import anyio +import httpx +from mcp_types.version import LATEST_MODERN_VERSION + +from mcp import StdioServerParameters, stdio_client +from mcp.client import Transport +from mcp.client.streamable_http import streamable_http_client +from mcp.server import Server +from mcp.server.mcpserver import MCPServer + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + +Target: TypeAlias = "Server[Any] | MCPServer | Transport | str" +"""Anything ``Client(...)`` accepts: an in-process server, a ``Transport``, or an HTTP URL.""" + +TargetFactory = Callable[[], Target] +"""Yields a FRESH target against the same server/app on every call (``multi_connection`` stories).""" + +AuthBuilder = Callable[[httpx.AsyncClient], httpx.Auth] +"""Builds an ``httpx.Auth`` bound to the in-process HTTP client (auth-story harness seam).""" + + +def argv_after(flag: str, *, default: str | None = None) -> str: + """Return the argv token following ``flag``, or ``default`` when the flag is absent.""" + try: + return sys.argv[sys.argv.index(flag) + 1] + except ValueError: + if default is None: + raise SystemExit(f"missing required {flag}") from None + return default + + +def target_from_args(file: str, url: str | None) -> TargetFactory: + """Build a ``TargetFactory`` for the sibling server of the ``client.py`` at ``file``. + + ``url`` (already resolved by ``run_client``) targets that streamable-HTTP endpoint; ``None`` + spawns ``.py`` over stdio per call, ```` from ``--server`` (default ``server``). + """ + if url is not None: + return lambda: url + # stdio is legacy-only until serve_stdio() lands; the modern arm is --http only for now. + server = Path(file).parent / f"{argv_after('--server', default='server')}.py" + params = StdioServerParameters(command=sys.executable, args=[str(server)]) + return lambda: stdio_client(params) # becomes Client(params) once that overload lands + + +def _explicit_http_url() -> str | None: + """The URL token after ``--http``, or ``None`` when the flag stands alone (self-host).""" + rest = sys.argv[sys.argv.index("--http") + 1 :] + return rest[0] if rest and not rest[0].startswith("-") else None + + +def _free_port() -> int: + """An OS-assigned free TCP port, released for the server subprocess to re-bind.""" + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +async def _accepting(port: int) -> bool: + """Whether something accepts a TCP connect on ``127.0.0.1:port`` right now.""" + try: + stream = await anyio.connect_tcp("127.0.0.1", port) + except OSError: + return False + await stream.aclose() + return True + + +@asynccontextmanager +async def _self_hosted(name: str, cfg: dict[str, Any]) -> AsyncIterator[str]: + """Serve the story's sibling server from a subprocess on a port this process owns; yield its URL. + + Readiness is the first accepted TCP connect (bounded by ``run_client``'s + ``anyio.fail_after``); exiting terminates the subprocess. Nothing to background or kill. + A subprocess that dies before serving, or a ``fixed_port`` someone else already holds, + is a loud ``SystemExit`` rather than a hang or a run against the wrong server. + """ + port: int = cfg["fixed_port"] or _free_port() + if cfg["fixed_port"] and await _accepting(port): + # The readiness probe below can't tell our child from a server already on the + # story's pinned port, so a foreign listener would be tested in its place. + raise SystemExit( + f"{name} self-hosts on :{port} but something is already serving there; " + f"stop it, or connect to it with --http " + ) + module = f"stories.{name}.{argv_after('--server', default='server')}" + serve = ["--http"] if cfg["server_export"] == "factory" else [] + argv = [sys.executable, "-m", module, *serve, "--port", str(port)] + async with await anyio.open_process(argv, stdout=None, stderr=None) as server: + try: + while server.returncode is None and not await _accepting(port): + await anyio.sleep(0.05) + if server.returncode is not None: + raise SystemExit(f"{module} exited {server.returncode} before serving on :{port}") + yield f"http://127.0.0.1:{port}{cfg['mcp_path']}" + finally: + if server.returncode is None: + server.terminate() + + +def _story_cfg(name: str) -> dict[str, Any]: + """The manifest entry for the story ``name`` with ``[defaults]`` applied.""" + manifest: dict[str, Any] = tomllib.loads((Path(__file__).parent / "manifest.toml").read_text()) + return manifest["defaults"] | manifest["story"].get(name, {}) + + +def _authed_targets(url: str, http: httpx.AsyncClient) -> TargetFactory: + """Fresh streamable-HTTP transports over an already-authed ``httpx`` client.""" + return lambda: streamable_http_client(url, http_client=http) + + +def run_client(main: Callable[..., Awaitable[None]]) -> None: + """Entry point for ``if __name__ == "__main__"`` in every ``client.py``. + + Resolves the argv target — stdio (the default), ``--http `` for a server you run, or + bare ``--http`` to self-host the sibling server in a subprocess it owns — and calls ``main`` + with an explicit ``mode=``. A ``build_auth`` export auths the HTTP target. ``OK``/``FAIL``, exit 0/1. + """ + globals_ = getattr(main, "__globals__", {}) + file = str(globals_.get("__file__", "")) + name = Path(file).parent.name + cfg = _story_cfg(name) + build_auth: AuthBuilder | None = globals_.get("build_auth") + transport = "http" if "--http" in sys.argv else "stdio" + if cfg["server_export"] == "app" and transport != "http": + raise SystemExit( + f"{name} exports an ASGI app (no stdio entry point); self-host it over HTTP:\n" + f" python -m stories.{name}.client --http" + ) + if cfg["needs_http"] and transport != "http": + raise SystemExit(f"{name} asserts on raw HTTP responses; run it with --http") + explicit_url = _explicit_http_url() if transport == "http" else None + # The era is an axis of the story matrix, so ``mode=`` is always passed explicitly + # even though it often matches the ``Client`` default of "auto". stdio is legacy-only + # until the SDK's stdio entry can negotiate the era, so only --http gets a modern arm. + era = "modern" if transport == "http" and "--legacy" not in sys.argv else "legacy" + if cfg["era"] in ("legacy", "modern"): + era = cfg["era"] + if cfg["era"] == "dual-in-body": + # The story pins its connection modes inside ``main`` itself, so hand it "auto" + # (the ``Client`` default) and let those in-body pins decide. A hard version pin + # here would skip the discover probe and leave ``server_info`` blank. + era = "in-body" + mode = {"modern": LATEST_MODERN_VERSION, "legacy": "legacy", "in-body": "auto"}[era] + + async def _run() -> None: + with anyio.fail_after(cfg["timeout_s"]): + async with AsyncExitStack() as stack: + url = explicit_url + if transport == "http" and url is None: + url = await stack.enter_async_context(_self_hosted(name, cfg)) + targets = target_from_args(file, url) + if url is None or (build_auth is None and not cfg["needs_http"]): + await main(targets if cfg["multi_connection"] else targets(), mode=mode) + return + # Auth and needs_http stories want the raw httpx client underneath the transport: + # build_auth threads an httpx.Auth onto it (Client(url, auth=...) doesn't exist + # yet), and needs_http stories assert on raw responses, so root the client at the + # server origin and relative paths like "/mcp" resolve. + parts = urlsplit(url) + base = f"{parts.scheme}://{parts.netloc}" + http = await stack.enter_async_context(httpx.AsyncClient(base_url=base)) + make = targets + if build_auth is not None: + http.auth = build_auth(http) + make = _authed_targets(url, http) + target: Any = make if cfg["multi_connection"] else make() + if cfg["needs_http"]: + await main(target, mode=mode, http=http) + else: + await main(target, mode=mode) + + try: + anyio.run(_run) + except Exception: + print(f"FAIL: {name} ({transport}/{era})", file=sys.stderr) + traceback.print_exc() + raise SystemExit(1) from None + print(f"OK: {name} ({transport}/{era})", file=sys.stderr) + raise SystemExit(0) diff --git a/examples/stories/_hosting.py b/examples/stories/_hosting.py new file mode 100644 index 0000000..0417786 --- /dev/null +++ b/examples/stories/_hosting.py @@ -0,0 +1,87 @@ +"""Server-side hosting scaffold for story examples. + +A story's ``server.py`` / ``server_lowlevel.py`` imports only from here. The +marked lines touch entry-point APIs that a later release reshapes into +free-function entries; isolating them here keeps story bodies stable. +""" + +from __future__ import annotations + +import sys +from collections.abc import Callable +from typing import Any, TypeAlias + +import anyio +import uvicorn +from starlette.applications import Starlette + +from mcp.server.lowlevel import Server +from mcp.server.mcpserver import MCPServer +from mcp.server.stdio import stdio_server +from mcp.server.transport_security import TransportSecuritySettings + +AnyServer: TypeAlias = "MCPServer | Server[Any]" +ServerFactory = Callable[[], AnyServer] +AppFactory = Callable[[], Starlette] + +NO_DNS_REBIND = TransportSecuritySettings(enable_dns_rebinding_protection=False) +"""Harness servers bind 127.0.0.1 and the in-process httpx client sends no Origin header.""" + + +def argv_after(flag: str, *, default: str | None = None) -> str: + """Return the argv token following ``flag``, or ``default`` when the flag is absent.""" + try: + return sys.argv[sys.argv.index(flag) + 1] + except ValueError: + if default is None: + raise SystemExit(f"missing required {flag}") from None + return default + + +def asgi_from(server: AnyServer, *, path: str = "/mcp") -> Starlette: + """Wrap a server instance in its streamable-HTTP ASGI app for in-process driving.""" + return server.streamable_http_app( # becomes free fn streamable_http(server, legacy=...) + streamable_http_path=path, + stateless_http=False, # bool folds into a legacy= enum in a later release + transport_security=NO_DNS_REBIND, + ) + + +def run_server_from_args(build_server: ServerFactory) -> None: + """Entry point for ``if __name__ == "__main__"`` in every ``server*.py``. + + Bare argv serves over stdio; ``--http --port N [--path /mcp]`` serves over + uvicorn on 127.0.0.1:N. + """ + server = build_server() + if "--http" in sys.argv: + port = int(argv_after("--port", default="8000")) + path = argv_after("--path", default="/mcp") + anyio.run(_serve_http, server, port, path) + else: + anyio.run(_serve_stdio, server) + + +async def _serve_stdio(server: AnyServer) -> None: + if isinstance(server, MCPServer): + await server.run_stdio_async() # becomes await serve_stdio(server) + else: + async with stdio_server() as (read, write): # becomes await serve_stdio(server) + await server.run(read, write, server.create_initialization_options()) + + +async def _serve_http(server: AnyServer, port: int, path: str) -> None: + app = asgi_from(server, path=path) + config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error") + await uvicorn.Server(config).serve() + + +def run_app_from_args(build_app: AppFactory) -> None: + """Entry point for ``if __name__ == "__main__"`` in app-exporting ``server*.py``. + + App-exporting stories are HTTP-only; ``--port N`` serves the Starlette app over + uvicorn on 127.0.0.1:N (uvicorn drives the app's own lifespan). No stdio leg. + """ + port = int(argv_after("--port", default="8000")) + config = uvicorn.Config(build_app(), host="127.0.0.1", port=port, log_level="error") + anyio.run(uvicorn.Server(config).serve) diff --git a/examples/stories/_shared/__init__.py b/examples/stories/_shared/__init__.py new file mode 100644 index 0000000..bf9e148 --- /dev/null +++ b/examples/stories/_shared/__init__.py @@ -0,0 +1 @@ +"""Shared scaffolding the auth/hosting stories import (not teaching surface).""" diff --git a/examples/stories/_shared/auth.py b/examples/stories/_shared/auth.py new file mode 100644 index 0000000..3bedcd3 --- /dev/null +++ b/examples/stories/_shared/auth.py @@ -0,0 +1,172 @@ +"""Minimal in-process OAuth pieces for the auth stories. + +A story-shaped subset; ``tests/interaction/auth`` keeps its own (richer) provider. +""" + +from __future__ import annotations + +import os +import secrets +import time +from urllib.parse import parse_qs, urlsplit + +import httpx +from pydantic import AnyHttpUrl + +from mcp.server.auth.provider import ( + AccessToken, + AuthorizationCode, + AuthorizationParams, + OAuthAuthorizationServerProvider, + RefreshToken, + construct_redirect_uri, +) +from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions +from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthToken + +BASE_URL = "http://127.0.0.1:8000" +MCP_URL = f"{BASE_URL}/mcp" +REDIRECT_URI = f"{BASE_URL}/oauth/callback" + + +class InMemoryTokenStorage: + """A ``TokenStorage`` that keeps tokens and DCR client info on instance attributes.""" + + tokens: OAuthToken | None = None + client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self.tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + self.tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self.client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self.client_info = client_info + + +class HeadlessOAuth: + """Completes the authorize redirect in-process via the bound ``httpx`` client.""" + + def __init__(self) -> None: + self.authorize_url: str | None = None + self._http: httpx.AsyncClient | None = None + self._result = AuthorizationCodeResult(code="", state=None) + + def bind(self, http_client: httpx.AsyncClient) -> None: + self._http = http_client + + async def redirect_handler(self, authorization_url: str) -> None: + assert self._http is not None + self.authorize_url = authorization_url + # ``auth=None`` is load-bearing: re-entering the locked auth flow would deadlock. + response = await self._http.get(authorization_url, follow_redirects=False, auth=None) + assert response.status_code == 302, f"authorize returned {response.status_code}: {response.text}" + params = parse_qs(urlsplit(response.headers["location"]).query) + self._result = AuthorizationCodeResult(code=params.get("code", [""])[0], state=params.get("state", [None])[0]) + + async def callback_handler(self) -> AuthorizationCodeResult: + return self._result + + +class InMemoryAuthorizationServerProvider( + OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken] +): + """Minimal demo AS: DCR + authorize + auth-code exchange held in instance dicts. + + ``authorize`` auto-consents only when ``OAUTH_DEMO_AUTO_CONSENT=1``; otherwise it redirects + with ``error=interaction_required`` so a manual run shows where a real browser would open. + """ + + def __init__(self) -> None: + self.clients: dict[str, OAuthClientInformationFull] = {} + self.codes: dict[str, AuthorizationCode] = {} + self.access_tokens: dict[str, AccessToken] = {} + + def mint_access_token( + self, *, client_id: str, scopes: list[str], resource: str | None = None, subject: str | None = None + ) -> str: + access = f"access_{secrets.token_hex(16)}" + self.access_tokens[access] = AccessToken( + token=access, + client_id=client_id, + scopes=scopes, + expires_at=int(time.time()) + 3600, + resource=resource, + subject=subject, + ) + return access + + async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: + return self.clients.get(client_id) + + async def register_client(self, client_info: OAuthClientInformationFull) -> None: + assert client_info.client_id is not None + self.clients[client_info.client_id] = client_info + + async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: + target = str(params.redirect_uri) + if os.environ.get("OAUTH_DEMO_AUTO_CONSENT") != "1": + return construct_redirect_uri(target, error="interaction_required", state=params.state) + assert client.client_id is not None + code = AuthorizationCode( + code=f"code_{secrets.token_hex(16)}", + client_id=client.client_id, + scopes=params.scopes or ["mcp"], + expires_at=time.time() + 300, + code_challenge=params.code_challenge, + redirect_uri=params.redirect_uri, + redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly, + resource=params.resource, + ) + self.codes[code.code] = code + return construct_redirect_uri(target, code=code.code, state=params.state) + + async def load_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: str + ) -> AuthorizationCode | None: + return self.codes.get(authorization_code) + + async def exchange_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode + ) -> OAuthToken: + scopes = authorization_code.scopes + access = self.mint_access_token( + client_id=authorization_code.client_id, scopes=scopes, resource=authorization_code.resource + ) + del self.codes[authorization_code.code] + return OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=" ".join(scopes)) + + async def load_access_token(self, token: str) -> AccessToken | None: + return self.access_tokens.get(token) + + async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None: + raise NotImplementedError + + async def exchange_refresh_token( + self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str] + ) -> OAuthToken: + raise NotImplementedError + + async def revoke_token(self, token: AccessToken | RefreshToken) -> None: + raise NotImplementedError + + +def auth_settings( + *, required_scopes: list[str] | None = None, identity_assertion_enabled: bool = False +) -> AuthSettings: + """``AuthSettings`` for the co-hosted demo AS+RS on the loopback origin, DCR enabled. + + ``identity_assertion_enabled`` passes through to the SEP-990 jwt-bearer grant flag. + """ + scopes = required_scopes or ["mcp"] + return AuthSettings( + issuer_url=AnyHttpUrl(BASE_URL), + resource_server_url=AnyHttpUrl(MCP_URL), + required_scopes=scopes, + client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=scopes, default_scopes=scopes), + identity_assertion_enabled=identity_assertion_enabled, + ) diff --git a/examples/stories/apps/README.md b/examples/stories/apps/README.md new file mode 100644 index 0000000..4041473 --- /dev/null +++ b/examples/stories/apps/README.md @@ -0,0 +1,40 @@ +# apps + +MCP Apps: a tool carries a `_meta.ui.resourceUri` reference to a `ui://` +resource that the host renders as an interactive surface. The server opts in via +the `Apps` extension (`io.modelcontextprotocol/ui`); the client negotiates it by +advertising the `text/html;profile=mcp-app` MIME type. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.apps.client + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.apps.client --http +``` + +## What to look at + +- `server.py` `MCPServer("apps-example", extensions=[apps])` — the extension + advertises `io.modelcontextprotocol/ui` under `ServerCapabilities.extensions` + and contributes the UI-bound tool and its `ui://` resource. `MCPServer` itself + never learns about "ui"; it applies a closed set of contributions. +- `server.py` `@apps.tool(resource_uri=...)` — stamps `_meta.ui.resourceUri` on + the tool; `add_html_resource` registers the matching `ui://` resource at + `text/html;profile=mcp-app`. +- `server.py` `client_supports_apps(ctx)` — SEP-2133 graceful degradation: a + client that did not negotiate Apps gets a text-only result. +- `client.py` `Client(target, extensions=[advertise(...)])` — the client advertises Apps + support so the server returns the UI-enabled result, then reads the tool's + `_meta.ui.resourceUri` and fetches that resource. + +## Spec + +[MCP Apps — extensions](https://modelcontextprotocol.io/specification/draft/extensions/apps) +· [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133) + +## See also + +`custom_methods/` (registering a non-spec method without an extension). diff --git a/examples/stories/apps/__init__.py b/examples/stories/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/apps/client.py b/examples/stories/apps/client.py new file mode 100644 index 0000000..dd79071 --- /dev/null +++ b/examples/stories/apps/client.py @@ -0,0 +1,37 @@ +"""Negotiate MCP Apps, discover a tool's `ui://` UI, fetch it, and call the tool.""" + +from mcp_types import TextContent, TextResourceContents + +from mcp.client import Client, advertise +from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID +from stories._harness import Target, run_client + + +async def main(target: Target, *, mode: str = "auto") -> None: + # Advertise MCP Apps support so the server returns the UI-enabled result; a + # client that omits this gets the text-only fallback (graceful degradation). + async with Client( + target, mode=mode, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})] + ) as client: + # The extensions capability map rides `server/discover` (modern only). On a + # legacy connection (today's stdio) it is absent, so assert it only when present. + if client.server_capabilities.extensions is not None: + assert client.server_capabilities.extensions == {EXTENSION_ID: {}}, client.server_capabilities.extensions + + listed = await client.list_tools() + tool = next(t for t in listed.tools if t.name == "get_time") + assert tool.meta is not None, tool + assert tool.meta["ui"]["resourceUri"] == "ui://get-time/app.html", tool.meta + + ui = await client.read_resource("ui://get-time/app.html") + contents = ui.contents[0] + assert isinstance(contents, TextResourceContents) + assert contents.mime_type == APP_MIME_TYPE, contents.mime_type + + result = await client.call_tool("get_time", {}) + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "2026-06-26T00:00:00Z", result.content[0].text + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/apps/server.py b/examples/stories/apps/server.py new file mode 100644 index 0000000..74d412e --- /dev/null +++ b/examples/stories/apps/server.py @@ -0,0 +1,43 @@ +"""MCP Apps: a tool bound to a `ui://` resource the host renders as an interactive surface. + +`Apps` is an opt-in `Extension` passed to `MCPServer(extensions=[...])`. The +`@apps.tool(resource_uri=...)` decorator stamps `_meta.ui.resourceUri` onto the +tool; `add_html_resource` registers the matching `ui://` HTML resource. The tool +degrades gracefully: `client_supports_apps(ctx)` reports whether the client +negotiated Apps, so it returns text-only output otherwise. +""" + +from mcp.server.apps import Apps, client_supports_apps +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.context import Context +from stories._hosting import run_server_from_args + +RESOURCE_URI = "ui://get-time/app.html" +CLOCK_HTML = """ +Current time +

+ +""" + + +def build_server() -> MCPServer: + apps = Apps() + + @apps.tool(resource_uri=RESOURCE_URI, title="Get Time", description="Return the current time.") + def get_time(ctx: Context) -> str: + now = "2026-06-26T00:00:00Z" + if not client_supports_apps(ctx): + return f"The time is {now}." + return now + + apps.add_html_resource(RESOURCE_URI, CLOCK_HTML, title="Clock") + return MCPServer("apps-example", extensions=[apps]) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/bearer_auth/README.md b/examples/stories/bearer_auth/README.md new file mode 100644 index 0000000..d1d556f --- /dev/null +++ b/examples/stories/bearer_auth/README.md @@ -0,0 +1,96 @@ +# bearer-auth + +Resource-server-only bearer auth. Pass a `TokenVerifier` + `AuthSettings` +(issuer, resource URL, required scopes) when building the streamable-HTTP app +and the SDK wires three things automatically: a bearer gate that answers 401 + +`WWW-Authenticate: Bearer ... resource_metadata=...` (or 403 `insufficient_scope`), +the RFC 9728 protected-resource-metadata document at +`/.well-known/oauth-protected-resource/mcp`, and the verified `AccessToken` +inside tool handlers via `get_access_token()`. The verifier here accepts one +static token — replace it with JWT verification or RFC 7662 introspection. No +authorization server; see `../oauth/` for the full grant flow. + +## Run it + +```bash +# HTTP — the client self-hosts the bearer-gated app, connects with the demo +# bearer token, then tears it down. Self-hosting uses this story's fixed :8000 +# (the issuer/PRM metadata pin it), so :8000 must be free. +uv run python -m stories.bearer_auth.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.bearer_auth.client --http --server server_lowlevel + +# against a server you run yourself (real uvicorn on :8000). The next section's +# curl probes use it too and `kill` it when done. While it is up it owns :8000, +# so the two self-host lines above refuse to run rather than test it by mistake. +uv run python -m stories.bearer_auth.server --port 8000 & +SERVER_PID=$! +uv run python -m stories.bearer_auth.client --http http://127.0.0.1:8000/mcp +``` + +`Client(url)` has no `auth=` passthrough, so a target built from a bare URL +can't carry the token. Both runners close that gap the same way: `run_client` +(above) and the pytest harness thread the module's `build_auth` export onto the +`httpx.AsyncClient` underneath the transport and hand `main` a target that is +already routed through it. + +## Try it without the SDK client + +```bash +# no token → 401 + WWW-Authenticate pointing at the PRM document +curl -i -X POST http://127.0.0.1:8000/mcp \ + -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"ping"}' + +# the RFC 9728 protected-resource-metadata document +curl -s http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp | jq + +# done with the server you started in "Run it" +kill "$SERVER_PID" +``` + +## What to look at + +- `client.py` `main` — opens with `async with Client(target, mode=mode) as + client:` and that is the whole program. The `target` it receives is a + transport that already carries the bearer token; nothing in the body knows + auth exists. +- `client.py` `build_auth` / `StaticBearerAuth` — bearer auth client-side is + five lines of `httpx.Auth`. `Client(url, auth=...)` is the ergonomic the SDK + is missing; until it lands, the auth has to be threaded onto the + `httpx.AsyncClient` underneath the transport, outside `main`. +- `server.py` — `MCPServer(token_verifier=..., auth=AuthSettings(...))` is the + whole recipe; `streamable_http_app()` reads those constructor kwargs and + mounts the bearer gate + PRM route. +- `server_lowlevel.py` — same gate, but `lowlevel.Server` takes + `auth=` / `token_verifier=` at **`streamable_http_app(...)` time**, not in the + constructor. `mcp.server.auth.*` imports are allowed in lowlevel files + (helper-tier). +- `whoami()` — `get_access_token()` returns the per-HTTP-request `AccessToken`. + It is **not** on `Context` (unlike other SDKs' `ctx.authInfo`); a later + release will namespace it as `ctx.transport.auth`. + +## Caveats + +- `transport_security=NO_DNS_REBIND` — DNS-rebinding protection is on by default + for localhost binds; the harness disables it because the in-process httpx + client sends no `Origin` header. Drop the kwarg for a real deployment. +- `RESOURCE_URL` is hard-coded to port 8000 (the harness's in-process origin). + If you change `--port`, edit `RESOURCE_URL` to match or the PRM document's + `resource` field will be wrong. +- Auth is HTTP-only; over stdio or the in-memory transport `get_access_token()` + returns `None` and there is no gate. +- The 401/403 status codes and `WWW-Authenticate` header are HTTP-level and + `Client` cannot observe them; they are pinned by + `tests/interaction/auth/test_bearer.py` and shown via `curl` above. + +## Spec + +[Authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) +· RFC 9728 (Protected Resource Metadata) · RFC 6750 (`WWW-Authenticate: Bearer`) + +## See also + +`oauth/` (full authorization-code grant with an in-process AS) · +`oauth_client_credentials/` (M2M `client_credentials` grant) · +`stateless_legacy/` (the un-gated hosting baseline). diff --git a/examples/stories/bearer_auth/__init__.py b/examples/stories/bearer_auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/bearer_auth/client.py b/examples/stories/bearer_auth/client.py new file mode 100644 index 0000000..5c419a0 --- /dev/null +++ b/examples/stories/bearer_auth/client.py @@ -0,0 +1,48 @@ +"""Call the bearer-gated server through an already-authed (``build_auth``, HTTP-only) transport; assert ``whoami``.""" + +from collections.abc import Generator + +import httpx + +from mcp.client import Client +from stories._harness import Target, run_client + +from .server import DEMO_TOKEN, REQUIRED_SCOPE + + +class StaticBearerAuth(httpx.Auth): + """``httpx.Auth`` that attaches a fixed ``Authorization: Bearer `` to every request.""" + + def __init__(self, token: str) -> None: + self.token = token + + def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]: + request.headers["Authorization"] = f"Bearer {self.token}" + yield request + + +def build_auth(_http: httpx.AsyncClient) -> httpx.Auth: + """The demo bearer token as an ``httpx.Auth``. + + ``Client(url, auth=...)`` doesn't exist yet, so the harness threads this onto the underlying + ``httpx.AsyncClient`` and the target ``main`` receives is already routed through it. + """ + return StaticBearerAuth(DEMO_TOKEN) + + +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + listed = await client.list_tools() + assert [t.name for t in listed.tools] == ["whoami"] + + result = await client.call_tool("whoami", {}) + assert not result.is_error, result + assert result.structured_content == { + "subject": "demo-user", + "client_id": "demo-client", + "scopes": [REQUIRED_SCOPE], + }, result.structured_content + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/bearer_auth/server.py b/examples/stories/bearer_auth/server.py new file mode 100644 index 0000000..45c9872 --- /dev/null +++ b/examples/stories/bearer_auth/server.py @@ -0,0 +1,56 @@ +"""Resource-server-only bearer auth: ``TokenVerifier``/``AuthSettings`` → 401/PRM/principal. Exports ``build_app()``.""" + +import time + +from pydantic import AnyHttpUrl +from starlette.applications import Starlette + +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.server.auth.settings import AuthSettings +from mcp.server.mcpserver import MCPServer +from stories._hosting import NO_DNS_REBIND, run_app_from_args + +ISSUER = "https://auth.example.com" +RESOURCE_URL = "http://127.0.0.1:8000/mcp" +REQUIRED_SCOPE = "mcp:read" +DEMO_TOKEN = "demo-token" + + +class StaticTokenVerifier(TokenVerifier): + """Accepts one hard-coded token. Replace with JWT verification or RFC 7662 introspection.""" + + async def verify_token(self, token: str) -> AccessToken | None: + if token != DEMO_TOKEN: + return None + return AccessToken( + token=token, + client_id="demo-client", + scopes=[REQUIRED_SCOPE], + expires_at=int(time.time()) + 3600, + subject="demo-user", + ) + + +def build_app() -> Starlette: + mcp = MCPServer( + "bearer-auth-example", + token_verifier=StaticTokenVerifier(), + auth=AuthSettings( + issuer_url=AnyHttpUrl(ISSUER), + resource_server_url=AnyHttpUrl(RESOURCE_URL), + required_scopes=[REQUIRED_SCOPE], + ), + ) + + @mcp.tool(description="Return the authenticated principal.") + def whoami() -> dict[str, str | list[str]]: + token = get_access_token() + assert token is not None # the bearer gate guarantees this on the HTTP path + return {"subject": token.subject or "", "client_id": token.client_id, "scopes": token.scopes} + + return mcp.streamable_http_app(transport_security=NO_DNS_REBIND) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/bearer_auth/server_lowlevel.py b/examples/stories/bearer_auth/server_lowlevel.py new file mode 100644 index 0000000..f5abfc0 --- /dev/null +++ b/examples/stories/bearer_auth/server_lowlevel.py @@ -0,0 +1,56 @@ +"""Resource-server-only bearer auth (lowlevel API): same gate, hand-built ``CallToolResult``.""" + +from typing import Any + +import mcp_types as types +from pydantic import AnyHttpUrl +from starlette.applications import Starlette + +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.settings import AuthSettings +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import NO_DNS_REBIND, run_app_from_args + +from .server import ISSUER, REQUIRED_SCOPE, RESOURCE_URL, StaticTokenVerifier + + +def build_app() -> Starlette: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="whoami", + description="Return the authenticated principal.", + input_schema={"type": "object"}, + ), + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "whoami" + token = get_access_token() + assert token is not None # the bearer gate guarantees this on the HTTP path + payload = {"subject": token.subject or "", "client_id": token.client_id, "scopes": token.scopes} + return types.CallToolResult( + content=[types.TextContent(text=f"{token.subject} via {token.client_id}")], + structured_content=payload, + ) + + server = Server("bearer-auth-example", on_list_tools=list_tools, on_call_tool=call_tool) + # lowlevel.Server takes auth at app-build time, not in the constructor (cf. MCPServer). + return server.streamable_http_app( + auth=AuthSettings( + issuer_url=AnyHttpUrl(ISSUER), + resource_server_url=AnyHttpUrl(RESOURCE_URL), + required_scopes=[REQUIRED_SCOPE], + ), + token_verifier=StaticTokenVerifier(), + transport_security=NO_DNS_REBIND, + ) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/caching/README.md b/examples/stories/caching/README.md new file mode 100644 index 0000000..be0bb48 --- /dev/null +++ b/examples/stories/caching/README.md @@ -0,0 +1,20 @@ +# caching + +A server stamps `CacheableResult` hints (`ttl_ms`, `cache_scope`) onto list and +read responses; a client honours them to skip redundant round-trips. The story +will show per-result overrides on `@mcp.resource()` / `@mcp.tool()` and the +client-side cache hit/miss path. + +**Status: not yet implemented.** Server-side stamping landed (defaults +`ttl_ms=0`, `cache_scope="private"`), but the per-result override hook and the +client honouring path are not implemented yet. An example today could only show +the defaults being emitted, not acted on. + +## Spec + +[Caching — basic utilities](https://modelcontextprotocol.io/specification/draft/basic/utilities/caching) + +## Working example elsewhere + +The TypeScript SDK ships a runnable `caching` story: +[typescript-sdk/examples/caching](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/caching). diff --git a/examples/stories/custom_methods/README.md b/examples/stories/custom_methods/README.md new file mode 100644 index 0000000..75f1502 --- /dev/null +++ b/examples/stories/custom_methods/README.md @@ -0,0 +1,51 @@ +# custom-methods + +Register and call a vendor-prefixed JSON-RPC method that is not part of the +MCP spec. The server uses the low-level `Server.add_request_handler` (there is +no `MCPServer` surface for this, so `server.py` is lowlevel-native and there is +no `server_lowlevel.py` sibling); the client drops to `client.session` to send +it. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.custom_methods.client + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.custom_methods.client --http +``` + +## What to look at + +- `client.py` `main` — the body opens with `Client(target, mode=mode)`. The + vendor request rides whichever protocol era `mode` selects; nothing else in + the story changes between eras. +- `server.py` `SearchParams` — subclasses `types.RequestParams` so `_meta` + (and on a 2026-07-28 connection, the reserved `io.modelcontextprotocol/*` + envelope keys) parse uniformly without extra code. +- `server.py` `add_request_handler("acme/search", SearchParams, search)` — the + method string is the wire `method`; use a vendor prefix so it can never + collide with a future spec method. +- `client.py` `client.session.send_request(...)` — `Client` only exposes spec + verbs, so vendor methods go through the underlying `ClientSession`. + `send_request` accepts any `types.Request` subclass. + +## Caveats + +- The TypeScript SDK's equivalent example also shows a custom server→client + **notification** (`acme/searchProgress`). The Python client can observe + vendor notifications via `NotificationBinding` (see + `docs/advanced/extensions.md`). That half is omitted here because the + lowlevel server has no surface for emitting vendor notifications yet. + +## Spec + +[Requests — basic protocol](https://modelcontextprotocol.io/specification/2025-11-25/basic#requests) +(JSON-RPC request shape; vendor method names live outside the spec's reserved +set). + +## See also + +`serve_one/` (the per-exchange driver that runs registered handlers), +`middleware/` (wrapping every registered handler, including vendor methods). diff --git a/examples/stories/custom_methods/__init__.py b/examples/stories/custom_methods/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/custom_methods/client.py b/examples/stories/custom_methods/client.py new file mode 100644 index 0000000..7bf27dd --- /dev/null +++ b/examples/stories/custom_methods/client.py @@ -0,0 +1,37 @@ +"""Send a vendor-prefixed request via the `client.session` escape hatch.""" + +from typing import Literal + +import mcp_types as types + +from mcp.client import Client +from stories._harness import Target, run_client + + +class SearchParams(types.RequestParams): + query: str + limit: int = 10 + + +class SearchRequest(types.Request[SearchParams, Literal["acme/search"]]): + method: Literal["acme/search"] = "acme/search" + params: SearchParams + + +class SearchResult(types.Result): + items: list[str] + + +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + # `Client` only exposes spec-defined verbs, so vendor methods have to drop one + # layer to `client.session` today — there is no `Client`-level API for them + # yet, and whether `.session` stays public is undecided. `send_request` + # accepts any `Request` subclass. + request = SearchRequest(params=SearchParams(query="mcp", limit=3)) + result = await client.session.send_request(request, SearchResult) + assert result.items == ["mcp-0", "mcp-1", "mcp-2"], result + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/custom_methods/server.py b/examples/stories/custom_methods/server.py new file mode 100644 index 0000000..260aff7 --- /dev/null +++ b/examples/stories/custom_methods/server.py @@ -0,0 +1,39 @@ +"""Register a vendor-prefixed JSON-RPC method on the low-level Server. + +`MCPServer` has no public surface for arbitrary method registration, so this +story's `server.py` is lowlevel-native (no `server_lowlevel.py` sibling). +""" + +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + + +class SearchParams(types.RequestParams): + """Subclass `RequestParams` so `_meta` (and the 2026 envelope keys) parse uniformly.""" + + query: str + limit: int = 10 + + +class SearchResult(types.Result): + items: list[str] + + +def build_server() -> Server[Any]: + server = Server("custom-methods-example") + + async def search(ctx: ServerRequestContext[Any], params: SearchParams) -> SearchResult: + items = [f"{params.query}-{i}" for i in range(params.limit)] + return SearchResult(items=items) + + server.add_request_handler("acme/search", SearchParams, search) + return server + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/dual_era/README.md b/examples/stories/dual_era/README.md new file mode 100644 index 0000000..f14f164 --- /dev/null +++ b/examples/stories/dual_era/README.md @@ -0,0 +1,58 @@ +# dual-era + +One server factory, both protocol eras. A `mode="legacy"` client runs the +`initialize` handshake; a `mode="auto"` client probes `server/discover` and +adopts the 2026 stateless era — the same `greet` tool answers both and reports +which era served it via `ctx.request_context.protocol_version`. **Start here** +when migrating a v1 server: the entry owns the era decision, the server body +stays era-agnostic. + +## Run it + +```bash +# over HTTP — the same /mcp endpoint serves both eras; the client self-hosts +# the server on a free port, runs, then tears it down +uv run python -m stories.dual_era.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.dual_era.client --http --server server_lowlevel +``` + +The bare stdio invocation (`uv run python -m stories.dual_era.client`) is +legacy-only until the SDK's stdio entry can negotiate the era, so the modern +leg fails there today — run over `--http`. + +## What to look at + +- `client.py` — both connections are visible, against the same `targets()` + factory: `Client(targets(), mode=mode)` (default `"auto"`, the + discover-then-fallback ladder) and `Client(targets(), mode="legacy")` (forces + the `initialize` handshake). The era decision is one explicit `mode=` argument + at construction; no date strings appear in the body. +- `client.py` — `client.protocol_version` / `client.server_info` / + `client.server_capabilities` are era-neutral: populated by `initialize` *or* + `server/discover`, whichever ran. +- `server.py` — `ctx.request_context.protocol_version` is the era branch key + (lowlevel: `ctx.protocol_version` directly). Compare against + `MODERN_PROTOCOL_VERSIONS`, never a date literal. +- **Where to read the negotiated version.** One value, three read paths: + `client.protocol_version` on the client after connect; `ctx.protocol_version` + inside a lowlevel handler; `ctx.request_context.protocol_version` inside an + `MCPServer` handler. + +## Caveats + +- `ctx.request_context.protocol_version` is the current way to read the + negotiated version; a later release will shorten it to `ctx.transport.*`. +- Over HTTP the built-in era branch is currently header-only — a 2026 client + that omits the `MCP-Protocol-Version` header is mis-routed to the legacy + path. The body-primary classifier lands in a later release. + +## Spec + +- [Versioning — backward compatibility](https://modelcontextprotocol.io/specification/draft/basic/versioning) +- [`server/discover`](https://modelcontextprotocol.io/specification/draft/server/discover) + +## See also + +`legacy_routing/` (route eras yourself), `reconnect/` (persist `DiscoverResult` +for zero-RTT reconnect). diff --git a/examples/stories/dual_era/__init__.py b/examples/stories/dual_era/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/dual_era/client.py b/examples/stories/dual_era/client.py new file mode 100644 index 0000000..ba9acf5 --- /dev/null +++ b/examples/stories/dual_era/client.py @@ -0,0 +1,41 @@ +"""Connect to the same server factory twice — once per era, so `main` takes `targets` — and assert both are served.""" + +import mcp_types as types +from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION + +from mcp.client import Client +from stories._harness import TargetFactory, run_client + + +async def main(targets: TargetFactory, *, mode: str = "auto") -> None: + # ── modern arm: the caller's mode (the real-user "auto" default) probes + # ``server/discover`` and adopts the result — no ``initialize`` handshake runs. + # The version/info/capabilities accessors are era-neutral. + async with Client(targets(), mode=mode) as modern: + assert modern.protocol_version == LATEST_MODERN_VERSION + assert modern.server_info.name == "dual-era-example" + assert modern.server_capabilities.tools is not None + + listed = await modern.list_tools() + assert [t.name for t in listed.tools] == ["greet"] + + result = await modern.call_tool("greet", {"name": "2026 client"}) + first = result.content[0] + assert isinstance(first, types.TextContent) + assert first.text == f"Hello, 2026 client! (served on the modern era at {LATEST_MODERN_VERSION})" + + # ── legacy arm: a fresh connection to the SAME server, pinned to the handshake era. + # The same accessors are populated identically — here by ``initialize``. + async with Client(targets(), mode="legacy") as legacy: + assert legacy.protocol_version == LATEST_HANDSHAKE_VERSION + assert legacy.server_info.name == "dual-era-example" + assert legacy.server_capabilities.tools is not None + + result = await legacy.call_tool("greet", {"name": "2025 client"}) + first = result.content[0] + assert isinstance(first, types.TextContent) + assert first.text == f"Hello, 2025 client! (served on the legacy era at {LATEST_HANDSHAKE_VERSION})" + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/dual_era/server.py b/examples/stories/dual_era/server.py new file mode 100644 index 0000000..3f70ee6 --- /dev/null +++ b/examples/stories/dual_era/server.py @@ -0,0 +1,25 @@ +"""One MCPServer factory that serves both the 2025 handshake era and the 2026 stateless era.""" + +from mcp_types.version import MODERN_PROTOCOL_VERSIONS + +from mcp.server.mcpserver import Context, MCPServer +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + # The same factory serves both eras with no configuration. Which era a request is + # on is decided by the entry point / transport, never by the server. + mcp = MCPServer("dual-era-example", instructions="A small dual-era demo server.") + + @mcp.tool() + async def greet(name: str, ctx: Context) -> str: + """Greet the caller and report which protocol era served the request.""" + pv = ctx.request_context.protocol_version + era = "modern" if pv in MODERN_PROTOCOL_VERSIONS else "legacy" + return f"Hello, {name}! (served on the {era} era at {pv})" + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/dual_era/server_lowlevel.py b/examples/stories/dual_era/server_lowlevel.py new file mode 100644 index 0000000..b209135 --- /dev/null +++ b/examples/stories/dual_era/server_lowlevel.py @@ -0,0 +1,50 @@ +"""One lowlevel Server factory that serves both the 2025 handshake era and the 2026 stateless era.""" + +from typing import Any + +import mcp_types as types +from mcp_types.version import MODERN_PROTOCOL_VERSIONS + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + +GREET_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], +} + + +def build_server() -> Server[Any]: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="greet", + description="Greet the caller and report which protocol era served the request.", + input_schema=GREET_INPUT_SCHEMA, + ), + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "greet" and params.arguments is not None + era = "modern" if ctx.protocol_version in MODERN_PROTOCOL_VERSIONS else "legacy" + text = f"Hello, {params.arguments['name']}! (served on the {era} era at {ctx.protocol_version})" + return types.CallToolResult(content=[types.TextContent(text=text)]) + + # The same factory serves both eras with no configuration. Which era a request is + # on is decided by the entry point / transport, never by the server. + return Server( + "dual-era-example", + instructions="A small dual-era demo server.", + on_list_tools=list_tools, + on_call_tool=call_tool, + ) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/error_handling/README.md b/examples/stories/error_handling/README.md new file mode 100644 index 0000000..475a2a0 --- /dev/null +++ b/examples/stories/error_handling/README.md @@ -0,0 +1,52 @@ +# error-handling + +Tool *execution* failures travel as a successful `CallToolResult` with +`is_error=True` so the LLM can read the message and self-correct. +*Protocol* failures travel as a JSON-RPC error that the client catches as +`MCPError`. This story shows how to produce each from a tool body — `raise +ToolError(...)` vs `raise MCPError(...)` on `MCPServer`; an explicit +`is_error=True` return vs `raise MCPError` on `lowlevel.Server` — and how a +client tells them apart. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.error_handling.client + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.error_handling.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.error_handling.client --http --server server_lowlevel +``` + +## What to look at + +- `client.py` `main` — opens with `async with Client(target, mode=mode) as + client:`. Inside it, `await` returns for `is_error` results and + `except MCPError` catches protocol errors; the client never auto-raises on + `is_error`. +- `server.py` — `raise ToolError(...)` vs `raise MCPError(...)`: same `raise` + keyword, opposite wire channel. The tool wrapper re-raises `MCPError` + verbatim and wraps everything else as an `is_error` result. +- `server_lowlevel.py` — no wrapper: you build `CallToolResult(is_error=True)` + yourself, and `MCPError` is the only way to pick a JSON-RPC error code. + +## Caveats + +- The "any other exception → `is_error` result" contract on `MCPServer` and the + "uncaught exception → `code=0`" behaviour on `lowlevel.Server` are **not + shown** — the contract is under design and the legacy code is a known spec + divergence. This story will grow those cases once the contract lands. +- `MCPServer` prefixes the execution-error message with + `"Error executing tool {name}: "`; build a `CallToolResult` directly from a + lowlevel handler if you need verbatim control. + +## Spec + +[Tools — error handling](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#error-handling) + +## See also + +`tools/` (the happy path), `streaming/` (cancellation as a third error-adjacent +surface). diff --git a/examples/stories/error_handling/__init__.py b/examples/stories/error_handling/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/error_handling/client.py b/examples/stories/error_handling/client.py new file mode 100644 index 0000000..872ec7f --- /dev/null +++ b/examples/stories/error_handling/client.py @@ -0,0 +1,38 @@ +"""Prove the two error channels: is_error results return; MCPError raises.""" + +from mcp_types import INVALID_PARAMS, TextContent + +from mcp import MCPError +from mcp.client import Client +from stories._harness import Target, run_client + + +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + # Success: is_error defaults to False. + ok = await client.call_tool("divide", {"a": 6, "b": 2}) + assert ok.is_error is False, ok + assert isinstance(ok.content[0], TextContent) + assert ok.content[0].text == "3.0" + + # Execution error: arrives as a *result* — await returns, no exception. + failed = await client.call_tool("divide", {"a": 1, "b": 0}) + assert failed.is_error is True, "execution errors ride CallToolResult, not an exception" + assert isinstance(failed.content[0], TextContent) + # MCPServer prefixes "Error executing tool divide: ..."; lowlevel returns + # the message verbatim. Assert the substring both produce. + assert "cannot divide by zero" in failed.content[0].text + + # Protocol error: arrives as a raised MCPError. + try: + await client.call_tool("restricted", {}) + except MCPError as e: + assert e.code == INVALID_PARAMS + assert e.message == "this tool is gated" + assert e.data == {"reason": "demo"} + else: + raise AssertionError("expected MCPError for a protocol-level rejection") + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/error_handling/server.py b/examples/stories/error_handling/server.py new file mode 100644 index 0000000..e4f3554 --- /dev/null +++ b/examples/stories/error_handling/server.py @@ -0,0 +1,35 @@ +"""Two error channels: ToolError -> is_error result; MCPError -> JSON-RPC protocol error.""" + +from mcp_types import INVALID_PARAMS + +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.exceptions import ToolError +from mcp.shared.exceptions import MCPError +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer("error-handling-example") + + @mcp.tool() + def divide(a: float, b: float) -> float: + """Divide a by b. Division by zero is an execution error the LLM should see.""" + if b == 0: + # ToolError is caught by the tool wrapper and returned as + # CallToolResult(is_error=True) — the LLM reads the message and can + # self-correct. + raise ToolError("cannot divide by zero") + return a / b + + @mcp.tool() + def restricted() -> str: + """A tool that always rejects the caller at the protocol level.""" + # MCPError escapes the tool wrapper and becomes a JSON-RPC error + # response — the *host* sees code/message/data, not the LLM. + raise MCPError(code=INVALID_PARAMS, message="this tool is gated", data={"reason": "demo"}) + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/error_handling/server_lowlevel.py b/examples/stories/error_handling/server_lowlevel.py new file mode 100644 index 0000000..9bb9aef --- /dev/null +++ b/examples/stories/error_handling/server_lowlevel.py @@ -0,0 +1,45 @@ +"""Two error channels on lowlevel.Server: return is_error=True yourself, or raise MCPError.""" + +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from mcp.shared.exceptions import MCPError +from stories._hosting import run_server_from_args + +_TOOLS = [ + types.Tool(name="divide", description="Divide a by b.", input_schema={"type": "object"}), + types.Tool(name="restricted", description="Always rejects.", input_schema={"type": "object"}), +] + + +def build_server() -> Server[Any]: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=_TOOLS) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + args = params.arguments or {} + if params.name == "divide": + a, b = float(args["a"]), float(args["b"]) + if b == 0: + # Execution error: build the is_error result yourself. + return types.CallToolResult( + content=[types.TextContent(text="cannot divide by zero")], + is_error=True, + ) + return types.CallToolResult(content=[types.TextContent(text=str(a / b))]) + if params.name == "restricted": + # Protocol error: raise MCPError; the dispatcher serialises it as a + # JSON-RPC error response with this code/message/data. + raise MCPError(code=types.INVALID_PARAMS, message="this tool is gated", data={"reason": "demo"}) + raise MCPError(code=types.INVALID_PARAMS, message=f"Unknown tool: {params.name}") + + return Server("error-handling-example", on_list_tools=list_tools, on_call_tool=call_tool) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/events/README.md b/examples/stories/events/README.md new file mode 100644 index 0000000..0fe7dc8 --- /dev/null +++ b/examples/stories/events/README.md @@ -0,0 +1,21 @@ +# events + +The `io.modelcontextprotocol/events` extension: poll, push, and webhook +delivery of server-originated events on top of the `subscriptions/listen` +channel. The story will show a server emitting events and a client consuming +them over each delivery mode. + +**Status: not yet implemented.** Depends on both the `subscriptions/listen` +runtime ([#2901](https://github.com/modelcontextprotocol/python-sdk/issues/2901)) +and the `extensions` capability map +([#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896)) — +neither has landed. + +## Spec + +[Events — extensions](https://modelcontextprotocol.io/specification/draft/extensions/events) +· [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133) + +## See also + +`subscriptions/` (the listen channel this builds on). diff --git a/examples/stories/extensions/README.md b/examples/stories/extensions/README.md new file mode 100644 index 0000000..4668f99 --- /dev/null +++ b/examples/stories/extensions/README.md @@ -0,0 +1,41 @@ +# extensions + +Writing your own extension (SEP-2133): one identifier bundles a settings entry +under `ServerCapabilities.extensions`, a contributed tool, and a vendor request +method gated on the client declaring the extension back. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.extensions.client + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.extensions.client --http +``` + +## What to look at + +- `server.py` `class Catalog(Extension)` — the whole extension: `settings()` + becomes the advertised capability entry, `tools()` contributes a regular tool, + `methods()` registers a vendor verb. The extension never holds the server; it + declares contributions and `MCPServer(extensions=[...])` consumes them. +- `server.py` `require_client_extension(ctx, EXTENSION_ID)` — the vendor method + rejects clients that did not declare the extension with `-32021` (missing + required client capability) and a machine-readable `requiredCapabilities` + payload. +- `client.py` `Client(target, extensions=[advertise(EXTENSION_ID)])` — the client-side + half of the negotiation; on 2026-07-28 it travels in the per-request `_meta` + envelope. +- `client.py` `client.session.send_request(...)` — vendor methods have no + `Client`-level helper; the session escape hatch sends them. + +## Spec + +[SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133) +· [Capabilities — `_meta` key grammar](https://modelcontextprotocol.io/specification/draft/basic/index) + +## See also + +`apps/` (the built-in MCP Apps extension) · `custom_methods/` (the same verb +registered on the lowlevel `Server` by hand, without an extension). diff --git a/examples/stories/extensions/__init__.py b/examples/stories/extensions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/extensions/client.py b/examples/stories/extensions/client.py new file mode 100644 index 0000000..0bb033d --- /dev/null +++ b/examples/stories/extensions/client.py @@ -0,0 +1,53 @@ +"""Discover an extension's capability entry, call its tool, then send its vendor method.""" + +from typing import Literal + +import mcp_types as types +from mcp_types import TextContent + +from mcp.client import Client, advertise +from stories._harness import Target, run_client + +EXTENSION_ID = "com.example/catalog" + + +class SearchParams(types.RequestParams): + query: str + limit: int = 3 + + +class SearchRequest(types.Request[SearchParams, Literal["com.example/search"]]): + method: Literal["com.example/search"] = "com.example/search" + params: SearchParams + + +class SearchResult(types.Result): + items: list[str] + + +async def main(target: Target, *, mode: str = "auto") -> None: + # Declare the extension client-side so the server's `require_client_extension` + # gate on `com.example/search` passes. + async with Client(target, mode=mode, extensions=[advertise(EXTENSION_ID)]) as client: + # The extensions capability map rides `server/discover` (modern only). On a + # legacy connection it is absent, so assert it only when present. + if client.server_capabilities.extensions is not None: + assert client.server_capabilities.extensions == {EXTENSION_ID: {"suggest": True}}, ( + client.server_capabilities.extensions + ) + + # The extension's tool is a regular tool: listed and callable like any other. + listed = await client.list_tools() + assert [tool.name for tool in listed.tools] == ["suggest"], listed + result = await client.call_tool("suggest", {"prefix": "mcp"}) + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "mcp-suggestion", result.content[0].text + + # Vendor methods drop one layer to `client.session` (see custom_methods/). + request = SearchRequest(params=SearchParams(query="mcp", limit=3)) + found = await client.session.send_request(request, SearchResult) + assert found.items == ["mcp-0", "mcp-1", "mcp-2"], found + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/extensions/server.py b/examples/stories/extensions/server.py new file mode 100644 index 0000000..837c668 --- /dev/null +++ b/examples/stories/extensions/server.py @@ -0,0 +1,64 @@ +"""Package a vendor verb and a tool as a reusable, advertised extension (SEP-2133). + +`custom_methods/` registers a verb on the lowlevel `Server` by hand; this story +bundles the same idea as an `Extension`: declared contributions, a settings entry +under `ServerCapabilities.extensions`, and a `require_client_extension` gate on +the vendor method. +""" + +from collections.abc import Sequence +from typing import Any + +import mcp_types as types +from pydantic import Field + +from mcp.server.context import ServerRequestContext +from mcp.server.extension import Extension, MethodBinding, ToolBinding +from mcp.server.mcpserver import MCPServer, require_client_extension +from stories._hosting import run_server_from_args + +EXTENSION_ID = "com.example/catalog" + + +class SearchParams(types.RequestParams): + """Subclass `RequestParams` so `_meta` (and the 2026 envelope keys) parse uniformly.""" + + query: str + limit: int = Field(default=3, ge=1, le=25) + + +class SearchResult(types.Result): + items: list[str] + + +def suggest(prefix: str) -> str: + """Suggest a catalog entry for a prefix.""" + return f"{prefix}-suggestion" + + +async def search(ctx: ServerRequestContext[Any, Any], params: SearchParams) -> SearchResult: + require_client_extension(ctx, EXTENSION_ID) + return SearchResult(items=[f"{params.query}-{n}" for n in range(params.limit)]) + + +class Catalog(Extension): + """One identifier, three contributions: settings, a tool, a vendor method.""" + + identifier = EXTENSION_ID + + def settings(self) -> dict[str, Any]: + return {"suggest": True} + + def tools(self) -> Sequence[ToolBinding]: + return [ToolBinding(fn=suggest)] + + def methods(self) -> Sequence[MethodBinding]: + return [MethodBinding("com.example/search", SearchParams, search)] + + +def build_server() -> MCPServer: + return MCPServer("extensions-example", extensions=[Catalog()]) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/identity_assertion/README.md b/examples/stories/identity_assertion/README.md new file mode 100644 index 0000000..34746ec --- /dev/null +++ b/examples/stories/identity_assertion/README.md @@ -0,0 +1,103 @@ +# identity-assertion + +SEP-990 (Enterprise-Managed Authorization): the enterprise identity provider, +not the end user, decides which MCP servers a client may reach. The IdP signs +that decision into an Identity Assertion JWT Authorization Grant (an ID-JAG); +the client presents it to the MCP authorization server under the RFC 7523 +`jwt-bearer` grant and gets an ordinary, audience-restricted access token back. +No browser, no consent screen, no dynamic client registration, no refresh +token. This story co-hosts the authorization server and the bearer-gated MCP +server on one app, stands in for the IdP with an in-process signer, and proves +the user the IdP named is the user the tool sees. + +## Run it + +```bash +# HTTP, self-hosted: the client spawns the co-hosted AS + MCP app, presents an +# ID-JAG, and asserts `whoami` reports the IdP's subject. Self-hosting uses +# this story's fixed :8000 (the issuer/PRM metadata bake it in), so :8000 must +# be free. +uv run python -m stories.identity_assertion.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.identity_assertion.client --http --server server_lowlevel + +# against a server you run yourself (real uvicorn on :8000). The next section's +# curl probes use it too and `kill` it when done. +uv run python -m stories.identity_assertion.server --port 8000 & +SERVER_PID=$! +uv run python -m stories.identity_assertion.client --http http://127.0.0.1:8000/mcp +``` + +`Client(url)` has no `auth=` passthrough, so both runners thread the module's +`build_auth` export (an `IdentityAssertionOAuthProvider`) onto the +`httpx.AsyncClient` underneath the transport and hand `main` a target that is +already routed through it. + +## Try it without the SDK client + +```bash +# the AS metadata advertises the jwt-bearer grant AND the ID-JAG grant profile +curl -s http://127.0.0.1:8000/.well-known/oauth-authorization-server \ + | jq '{grant_types_supported, authorization_grant_profiles_supported}' + +# dynamic client registration refuses the jwt-bearer grant: an ID-JAG client +# must be pre-registered out of band +curl -si http://127.0.0.1:8000/register -H 'content-type: application/json' \ + -d '{"redirect_uris":["http://localhost:3030/cb"],"grant_types":["authorization_code","urn:ietf:params:oauth:grant-type:jwt-bearer"]}' \ + | head -1 + +# done with the server you started in "Run it" +kill "$SERVER_PID" +``` + +## What to look at + +- `client.py` `fetch_id_jag` — the one seam the SDK leaves you: given the + authorization server's issuer and the MCP server's resource identifier, + return a fresh ID-JAG. In production this is an RFC 8693 token exchange + against your IdP; here it calls the stand-in signer in `idp.py`. +- `client.py` `build_auth` — `IdentityAssertionOAuthProvider` is the same + `httpx.Auth` shape as every other provider. Note `issuer=ISSUER` with the + trailing slash: the provider compares it to the metadata document's `issuer` + by simple string comparison and refuses a mismatch before sending anything. +- `server.py` `exchange_identity_assertion` — the whole authorization-server + hook. The SDK authenticates the client and gates the grant; the signature, + `typ`, `aud`, `client_id`-match, `jti`-replay, and audience-restriction + checks inside the hook are the implementation's job. +- `server.py` `build_app` — `auth_settings(identity_assertion_enabled=True)` + is the one flag. Off (the default), `/token` answers the grant with + `unsupported_grant_type` even when the hook is implemented. +- `idp.py` — the claims an ID-JAG carries (`iss`, `sub`, `aud`, `client_id`, + `resource`, `scope`, `jti`, `iat`, `exp`) and its `typ: oauth-id-jag+jwt` + header. + +## Caveats + +- The IdP here is a module, not a service, and it signs with a shared HMAC + secret so the client process and a separately launched server process agree + on it. A real IdP signs with its private key, the authorization server + verifies against the IdP's published JWKS, and the client obtains the ID-JAG + over the network with an RFC 8693 token exchange. +- Co-hosting the authorization server and the MCP server on one app + (`auth_server_provider=`) is a demo convenience. SEP-990's model keeps them + separate, and either way the client only ever learns about the authorization + server from its own configuration, never from the MCP server. +- The provider's state is in-memory demo state: `seen_jtis` and the issued + tokens only ever grow. A real server evicts a `jti` once the assertion's + `exp` has passed and expires tokens out of its own store. +- `transport_security=NO_DNS_REBIND` is harness-only; drop it for a real + deployment. +- Auth is HTTP-only; over stdio or the in-memory transport there is no gate. + +## Spec + +[Enterprise-Managed Authorization (SEP-990)](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization) +· RFC 7523 (JWT bearer grant: the leg the SDK implements) +· RFC 8693 (token exchange: the IdP leg the SDK leaves to you) +· `draft-ietf-oauth-identity-assertion-authz-grant` (the ID-JAG profile) + +## See also + +`oauth/` (the interactive `authorization_code` grant) · +`oauth_client_credentials/` (machine to machine, no user at all) · +`bearer_auth/` (the resource-server half on its own). diff --git a/examples/stories/identity_assertion/__init__.py b/examples/stories/identity_assertion/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/identity_assertion/client.py b/examples/stories/identity_assertion/client.py new file mode 100644 index 0000000..bd13909 --- /dev/null +++ b/examples/stories/identity_assertion/client.py @@ -0,0 +1,69 @@ +"""HTTP-only SEP-990: `build_auth` presents an IdP-issued ID-JAG (jwt-bearer grant); `whoami` proves the subject.""" + +import httpx + +from mcp.client import Client +from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider +from stories._harness import Target, run_client +from stories._shared.auth import MCP_URL, InMemoryTokenStorage + +from .idp import issue_id_jag +from .server import DEMO_CLIENT_ID, DEMO_CLIENT_SECRET, DEMO_SCOPE, ISSUER + +# The end user the stand-in IdP says is signed in. +DEMO_SUBJECT = "alice@example.com" + + +async def fetch_id_jag(audience: str, resource: str) -> str: + """Step one, the part the SDK does not do: obtain a fresh ID-JAG from the enterprise IdP. + + A real implementation makes an RFC 8693 token-exchange request to the IdP, presenting the + signed-in user's ID token; `audience` (the authorization server's issuer) and `resource` (the + MCP server's identifier) pass straight through into the ID-JAG's `aud` and `resource` claims. + Here the stand-in IdP signs one in-process instead. + """ + return issue_id_jag( + subject=DEMO_SUBJECT, client_id=DEMO_CLIENT_ID, audience=audience, resource=resource, scope=DEMO_SCOPE + ) + + +def build_auth(_http: httpx.AsyncClient) -> httpx.Auth: + """An `IdentityAssertionOAuthProvider` for the pre-registered confidential client. + + `issuer` is configuration, not discovery: the provider fetches metadata from this issuer's + well-known and never asks the MCP server which authorization server to use. The string must + equal the `issuer` its metadata serves byte for byte (note the trailing slash). + `Client(url, auth=...)` doesn't exist yet, so the harness threads this onto the underlying + `httpx.AsyncClient` and hands `main` a target that is already routed through it. + """ + return IdentityAssertionOAuthProvider( + server_url=MCP_URL, + storage=InMemoryTokenStorage(), + client_id=DEMO_CLIENT_ID, + client_secret=DEMO_CLIENT_SECRET, + issuer=ISSUER, + assertion_provider=fetch_id_jag, + scope=DEMO_SCOPE, + ) + + +async def main(target: Target, *, mode: str = "auto") -> None: + # The target is already routed through `build_auth`'s provider. The first request 401s; the + # provider fetches the authorization server's metadata from the configured issuer (never from + # the MCP server), mints a fresh ID-JAG through `fetch_id_jag`, exchanges it at `/token` under + # the jwt-bearer grant, and retries with the bearer. No `/authorize`, no `/register`, no browser. + async with Client(target, mode=mode) as client: + listed = await client.list_tools() + assert [t.name for t in listed.tools] == ["whoami"] + + result = await client.call_tool("whoami", {}) + assert not result.is_error, result + assert result.structured_content == { + "subject": DEMO_SUBJECT, + "client_id": DEMO_CLIENT_ID, + "scopes": [DEMO_SCOPE], + }, result.structured_content + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/identity_assertion/idp.py b/examples/stories/identity_assertion/idp.py new file mode 100644 index 0000000..5d77c66 --- /dev/null +++ b/examples/stories/identity_assertion/idp.py @@ -0,0 +1,43 @@ +"""A stand-in enterprise identity provider: it signs the ID-JAGs the demo authorization server trusts. + +In production the IdP is a separate service (Okta, Microsoft Entra ID, ...) and the client obtains +the ID-JAG from it with an RFC 8693 token-exchange request, presenting the signed-in user's ID +token. `issue_id_jag` collapses that whole step into one in-process signing call so the story runs +unattended; the README's caveats spell out what a real deployment changes. +""" + +import time +import uuid + +import jwt + +IDP_ISSUER = "https://idp.example.com" +# Demo only: a real IdP signs with its private key and the authorization server verifies the +# signature against the IdP's published JWKS. A shared HMAC secret keeps this story self-contained. +IDP_SIGNING_KEY = "demo-idp-signing-key" + + +def issue_id_jag(*, subject: str, client_id: str, audience: str, resource: str, scope: str) -> str: + """The IdP's short-lived, signed statement that `subject`, via `client_id`, may reach `resource`. + + This is where the enterprise enforces policy: an IdP that does not authorize the combination + simply never issues the ID-JAG, and there is nothing for the client to present. The `typ` + header and the claim set are fixed by the Identity Assertion JWT Authorization Grant profile. + """ + now = int(time.time()) + return jwt.encode( + { + "iss": IDP_ISSUER, + "sub": subject, + "aud": audience, + "client_id": client_id, + "resource": resource, + "scope": scope, + "jti": str(uuid.uuid4()), + "iat": now, + "exp": now + 300, + }, + IDP_SIGNING_KEY, + algorithm="HS256", + headers={"typ": "oauth-id-jag+jwt"}, + ) diff --git a/examples/stories/identity_assertion/server.py b/examples/stories/identity_assertion/server.py new file mode 100644 index 0000000..8b0c8f4 --- /dev/null +++ b/examples/stories/identity_assertion/server.py @@ -0,0 +1,110 @@ +"""SEP-990 authorization server + bearer-gated MCP server on one app; exports `build_app()`. + +`identity_assertion_enabled=True` turns the RFC 7523 jwt-bearer grant on, and the provider's +`exchange_identity_assertion` validates the IdP-signed ID-JAG and mints an access token bound to +the user and resource the assertion names. The MCP server half is ordinary bearer auth. +""" + +import jwt +from pydantic import BaseModel +from starlette.applications import Starlette + +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.provider import IdentityAssertionParams, TokenError +from mcp.server.mcpserver import MCPServer +from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken +from stories._hosting import NO_DNS_REBIND, run_app_from_args +from stories._shared.auth import MCP_URL, InMemoryAuthorizationServerProvider, auth_settings + +from .idp import IDP_ISSUER, IDP_SIGNING_KEY + +# DEMO ONLY: never hard-code real credentials. +DEMO_CLIENT_ID = "finance-agent" +DEMO_CLIENT_SECRET = "demo-finance-agent-secret" +DEMO_SCOPE = "mcp" +# The exact `issuer` string this authorization server's metadata serves. The client must configure +# the byte-identical string: RFC 8414 issuer comparison is character for character, and the +# settings' `AnyHttpUrl` renders the path-less loopback origin with a trailing slash. +ISSUER = str(auth_settings().issuer_url) + + +class Whoami(BaseModel): + subject: str + client_id: str + scopes: list[str] + + +class IdentityAssertionAuthorizationServer(InMemoryAuthorizationServerProvider): + """The demo in-process AS plus the SEP-990 hook: validate an ID-JAG, mint a bound token.""" + + def __init__(self) -> None: + super().__init__() + self.seen_jtis: set[str] = set() + # Pre-registered out of band. Dynamic client registration refuses the jwt-bearer grant, + # so an ID-JAG client always arrives already known and already confidential. + self.clients[DEMO_CLIENT_ID] = OAuthClientInformationFull( + client_id=DEMO_CLIENT_ID, + client_secret=DEMO_CLIENT_SECRET, + redirect_uris=None, + grant_types=[JWT_BEARER_GRANT_TYPE], + token_endpoint_auth_method="client_secret_post", + ) + + async def exchange_identity_assertion( + self, client: OAuthClientInformationFull, params: IdentityAssertionParams + ) -> OAuthToken: + """Validate the ID-JAG per RFC 7523 §3 and the SEP-990 processing rules, then issue the token.""" + try: + header = jwt.get_unverified_header(params.assertion) + claims = jwt.decode( + params.assertion, + IDP_SIGNING_KEY, + algorithms=["HS256"], + issuer=IDP_ISSUER, + audience=ISSUER, + options={"require": ["iss", "sub", "aud", "exp", "iat", "jti", "client_id", "resource", "scope"]}, + ) + except jwt.InvalidTokenError as error: + raise TokenError("invalid_grant", "the assertion did not verify") from error + if header.get("typ") != "oauth-id-jag+jwt": + raise TokenError("invalid_grant", "the assertion is not an ID-JAG") + if claims["client_id"] != client.client_id: + raise TokenError("invalid_grant", "the assertion was issued to a different client") + if claims["resource"] != MCP_URL: + raise TokenError("invalid_target", "the assertion is for a resource this server does not serve") + if claims["jti"] in self.seen_jtis: + raise TokenError("invalid_grant", "the assertion has already been used") + self.seen_jtis.add(claims["jti"]) + # Everything on the issued token comes from the validated assertion, the audience + # restriction above all: it binds the token to the ID-JAG's `resource` claim, never to + # the client-controlled `params.resource`. No refresh token is returned either; the IdP + # owns session lifetime by deciding whether to issue the next ID-JAG. + scopes = claims["scope"].split() + access = self.mint_access_token( + client_id=claims["client_id"], scopes=scopes, resource=claims["resource"], subject=claims["sub"] + ) + return OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=" ".join(scopes)) + + +def build_app() -> Starlette: + provider = IdentityAssertionAuthorizationServer() + # `auth_server_provider=` alone is enough: MCPServer derives a token verifier from it + # (passing both trips the mutex guard). + mcp = MCPServer( + "identity-assertion-example", + auth=auth_settings(required_scopes=[DEMO_SCOPE], identity_assertion_enabled=True), + auth_server_provider=provider, + ) + + @mcp.tool(description="Return the end user the ID-JAG named, plus the authenticated client and scopes.") + def whoami() -> Whoami: + token = get_access_token() + assert token is not None + assert token.subject is not None + return Whoami(subject=token.subject, client_id=token.client_id, scopes=token.scopes) + + return mcp.streamable_http_app(transport_security=NO_DNS_REBIND) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/identity_assertion/server_lowlevel.py b/examples/stories/identity_assertion/server_lowlevel.py new file mode 100644 index 0000000..1fcf8de --- /dev/null +++ b/examples/stories/identity_assertion/server_lowlevel.py @@ -0,0 +1,65 @@ +"""SEP-990 authorization server + bearer-gated MCP server (lowlevel API); same app shape.""" + +import json +from typing import Any + +import mcp_types as types +from starlette.applications import Starlette + +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.provider import ProviderTokenVerifier +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import NO_DNS_REBIND, run_app_from_args +from stories._shared.auth import auth_settings + +from .server import DEMO_SCOPE, IdentityAssertionAuthorizationServer + +WHOAMI_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "subject": {"type": "string"}, + "client_id": {"type": "string"}, + "scopes": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["subject", "client_id", "scopes"], +} + + +def build_app() -> Starlette: + provider = IdentityAssertionAuthorizationServer() + + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="whoami", + description="Return the end user the ID-JAG named, plus the authenticated client and scopes.", + input_schema={"type": "object"}, + output_schema=WHOAMI_OUTPUT_SCHEMA, + ), + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "whoami" + token = get_access_token() + assert token is not None + assert token.subject is not None + payload = {"subject": token.subject, "client_id": token.client_id, "scopes": token.scopes} + return types.CallToolResult(content=[types.TextContent(text=json.dumps(payload))], structured_content=payload) + + server = Server("identity-assertion-example", on_list_tools=list_tools, on_call_tool=call_tool) + # Unlike MCPServer (auth on the constructor), lowlevel.Server takes auth at app-build time. + return server.streamable_http_app( + auth=auth_settings(required_scopes=[DEMO_SCOPE], identity_assertion_enabled=True), + token_verifier=ProviderTokenVerifier(provider), + auth_server_provider=provider, + transport_security=NO_DNS_REBIND, + ) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/json_response/README.md b/examples/stories/json_response/README.md new file mode 100644 index 0000000..e7dff00 --- /dev/null +++ b/examples/stories/json_response/README.md @@ -0,0 +1,70 @@ +# json-response + +`streamable_http_app(json_response=True)` — one `application/json` body per +request instead of an SSE stream. Useful for serverless / edge runtimes that +can't hold a stream open. The 2026-07-28 path is stateless and JSON-only today +regardless of the flag; setting it makes the legacy (2025-era) branch on the +same endpoint behave the same way. + +## Run it + +```bash +# HTTP — the client self-hosts the app on a free port, runs the high-level +# Client + raw-envelope probe, then tears it down +uv run python -m stories.json_response.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.json_response.client --http --server server_lowlevel + +# against a server you run yourself (real uvicorn on :8000) +uv run python -m stories.json_response.server --port 8000 & +SERVER_PID=$! +uv run python -m stories.json_response.client --http http://127.0.0.1:8000/mcp + +# or POST the raw envelope yourself +curl -s http://127.0.0.1:8000/mcp \ + -H 'content-type: application/json' \ + -H 'accept: application/json, text/event-stream' \ + -H 'mcp-protocol-version: 2026-07-28' \ + -H 'mcp-method: tools/list' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"0"},"io.modelcontextprotocol/clientCapabilities":{}}}}' +kill "$SERVER_PID" +``` + +## What to look at + +- `client.py` `main` — `async with Client(target, mode=mode) as client:` is an + ordinary high-level client; nothing about JSON mode is visible from this side. + The same `main` also takes the raw `httpx.AsyncClient` so it can prove what + the wire looks like underneath. +- `client.py` `RAW_ENVELOPE_BODY` / `MODERN_HEADERS` — the exact 2026 wire + shape: three `io.modelcontextprotocol/*` `_meta` keys replace the initialize + handshake; `MCP-Protocol-Version` + `Mcp-Method` headers mirror the body so + gateways can route without parsing JSON. `main` posts it by hand and asserts + a single `application/json` response with no `Mcp-Session-Id`. +- `server.py` `greet` calls `ctx.report_progress(0.5)` — and `main` proves the + client's `progress_callback` is **never invoked**: JSON mode has no + back-channel for mid-call notifications (the `progress_seen == []` assertion + flips to `== [0.5]` once SSE buffering lands for the modern path). +- `server_lowlevel.py` — same ASGI app built from `lowlevel.Server`; the + `json_response=` / `transport_security=` knobs live on `streamable_http_app`, + not the server class. + +## Caveats + +- DNS-rebinding protection is on by default; the harness disables it via + `NO_DNS_REBIND` because the in-process httpx client sends no `Origin` header. +- The `streamable_http_app()` call shape here will move when the free-function + entry lands (see `_hosting.py`). +- `Mcp-Name` is omitted for `tools/list` because the SDK only emits it on + `tools/call` today. + +## Spec + +[Streamable HTTP — 2026-07-28](https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http) +· [SEP-2243 standard headers](https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#standard-request-headers) + +## See also + +`stateless_legacy/` (the one-liner `stateless_http=True` deploy), +`legacy_routing/` (route by era at the entry), `streaming/` (progress that *is* +delivered — over stdio/SSE). diff --git a/examples/stories/json_response/__init__.py b/examples/stories/json_response/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/json_response/client.py b/examples/stories/json_response/client.py new file mode 100644 index 0000000..08af5ef --- /dev/null +++ b/examples/stories/json_response/client.py @@ -0,0 +1,69 @@ +"""Plain ``Client`` against a JSON-only server: mid-call progress drops. HTTP-only — ``main`` also takes ``http``. + +``RAW_ENVELOPE_BODY`` / ``MODERN_HEADERS`` are the exact wire shape a 2026-era client +sends — this is the only story that shows it. ``main`` posts that body by hand and +asserts the response is a single ``application/json`` body with no session id. +""" + +import httpx +from mcp_types import TextContent +from mcp_types.version import LATEST_MODERN_VERSION + +from mcp.client import Client +from stories._harness import Target, run_client + +# The raw 2026-07-28 POST envelope: per-request `_meta` replaces the initialize handshake. +# The key/header strings are spelled out on purpose — this is the raw-wire story. In code +# use the named constants instead: `mcp_types.PROTOCOL_VERSION_META_KEY` / +# `CLIENT_INFO_META_KEY` / `CLIENT_CAPABILITIES_META_KEY` and +# `mcp.shared.inbound.MCP_PROTOCOL_VERSION_HEADER` (`legacy_routing/` shows that form). +RAW_ENVELOPE_BODY: dict[str, object] = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": LATEST_MODERN_VERSION, + "io.modelcontextprotocol/clientInfo": {"name": "raw-probe", "version": "0.0.0"}, + "io.modelcontextprotocol/clientCapabilities": {}, + } + }, +} +MODERN_HEADERS: dict[str, str] = { + "accept": "application/json, text/event-stream", + "content-type": "application/json", + "mcp-protocol-version": LATEST_MODERN_VERSION, + "mcp-method": "tools/list", +} + + +async def main(target: Target, *, mode: str = "auto", http: httpx.AsyncClient) -> None: + async with Client(target, mode=mode) as client: + assert client.protocol_version == LATEST_MODERN_VERSION + + progress_seen: list[float] = [] + + async def on_progress(progress: float, total: float | None, message: str | None) -> None: + progress_seen.append(progress) + + result = await client.call_tool("greet", {"name": "json"}, progress_callback=on_progress) + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Hello, json!" + assert result.structured_content == {"result": "Hello, json!"}, result + + # The tool called report_progress(0.5) but the modern HTTP JSON path has no + # back-channel for mid-call notifications, so the callback is never invoked. + assert progress_seen == [], f"expected progress to be dropped, got {progress_seen}" + + # Hand-craft a 2026 POST and assert it comes back as a single JSON body, no session. + response = await http.post("/mcp", json=RAW_ENVELOPE_BODY, headers=MODERN_HEADERS) + assert response.status_code == 200, response.text + assert response.headers["content-type"].split(";", 1)[0] == "application/json" + assert "mcp-session-id" not in response.headers + payload = response.json() + assert payload["id"] == 1 + assert [t["name"] for t in payload["result"]["tools"]] == ["greet"] + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/json_response/server.py b/examples/stories/json_response/server.py new file mode 100644 index 0000000..c09aca7 --- /dev/null +++ b/examples/stories/json_response/server.py @@ -0,0 +1,27 @@ +"""Serve over Streamable HTTP with JSON responses (no SSE stream); HTTP-only, so this exports ``build_app()``. + +The 2026-07-28 path is stateless and JSON-only by construction today; the +``json_response=True`` flag also forces JSON for the legacy (2025-era) branch on +the same endpoint. Mid-call notifications are dropped. +""" + +from starlette.applications import Starlette + +from mcp.server.mcpserver import Context, MCPServer +from stories._hosting import NO_DNS_REBIND, run_app_from_args + + +def build_app() -> Starlette: + mcp = MCPServer("json-response-example") + + @mcp.tool() + async def greet(name: str, ctx: Context) -> str: + """Report progress mid-call, then return a greeting.""" + await ctx.report_progress(0.5, total=1.0, message="halfway") + return f"Hello, {name}!" + + return mcp.streamable_http_app(json_response=True, transport_security=NO_DNS_REBIND) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/json_response/server_lowlevel.py b/examples/stories/json_response/server_lowlevel.py new file mode 100644 index 0000000..bcb14eb --- /dev/null +++ b/examples/stories/json_response/server_lowlevel.py @@ -0,0 +1,44 @@ +"""Serve over Streamable HTTP with JSON responses (lowlevel API).""" + +from typing import Any + +import mcp_types as types +from starlette.applications import Starlette + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import NO_DNS_REBIND, run_app_from_args + +GREET_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], +} + + +def build_app() -> Starlette: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="greet", + description="Report progress mid-call, then return a greeting.", + input_schema=GREET_INPUT_SCHEMA, + ) + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "greet" and params.arguments is not None + await ctx.session.report_progress(0.5, total=1.0, message="halfway") + text = f"Hello, {params.arguments['name']}!" + return types.CallToolResult(content=[types.TextContent(text=text)], structured_content={"result": text}) + + server = Server("json-response-example", on_list_tools=list_tools, on_call_tool=call_tool) + return server.streamable_http_app(json_response=True, transport_security=NO_DNS_REBIND) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/legacy_elicitation/README.md b/examples/stories/legacy_elicitation/README.md new file mode 100644 index 0000000..e9812ac --- /dev/null +++ b/examples/stories/legacy_elicitation/README.md @@ -0,0 +1,73 @@ +# legacy-elicitation + +> **Legacy mechanism (2025 handshake era).** This story shows the push-style +> server→client `elicitation/create` request; the 2026-07-28 protocol carries +> elicitation as an `InputRequiredResult` round-trip instead — that path is the +> [`mrtr/`](../mrtr/) story. Elicitation itself is **not** deprecated. +> TODO(maxisbey): unify once the MRTR runtime lands +> ([#2898](https://github.com/modelcontextprotocol/python-sdk/issues/2898)). +> The TypeScript SDK ships a single dual-era `elicitation/` story; this +> directory re-merges back into `elicitation/` once MRTR lands. + +A tool pauses mid-call to ask the user for structured input. On the +handshake-era protocol the server pushes an `elicitation/create` *request* to +the client and blocks until the client's `elicitation_callback` answers +`accept` / `decline` / `cancel`. Two modes: **form** (`ctx.elicit(message, +PydanticModel)` — schema derived from the model, accepted content validated +back into it) and **url** (`ctx.elicit_url(...)` — directs the user out-of-band +for OAuth / payment flows; `send_elicit_complete` notifies the client when the +flow finishes). + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.legacy_elicitation.client + +# HTTP — the client self-hosts the server on a free port, runs, then tears it +# down (--legacy: the push request needs the handshake era) +uv run python -m stories.legacy_elicitation.client --http --legacy +# same, against the lowlevel-API server variant +uv run python -m stories.legacy_elicitation.client --http --legacy --server server_lowlevel +``` + +## What to look at + +- `client.py` `main` — the whole client setup is one visible construction: + `Client(target, mode=mode, elicitation_callback=on_elicit)`. Supplying + `elicitation_callback` is what advertises the `elicitation: {form, url}` + capability; `on_elicit` serves *both* modes by branching on + `isinstance(params, ElicitRequestURLParams)`. +- `server.py` `register_user` — `await ctx.elicit("...", Registration)` derives + the form schema from the pydantic model and returns a typed + `ElicitationResult[Registration]`; narrow with `isinstance(answer, + AcceptedElicitation)` before reading `answer.data`. +- `server.py` `link_account` — `ctx.elicit_url(...)` for out-of-band flows; + after the user finishes, `send_elicit_complete` emits + `notifications/elicitation/complete` so the client can correlate. +- `server_lowlevel.py` — the same flow via `ctx.session.elicit_form` / + `ctx.session.elicit_url` and a hand-written `requestedSchema`. + +## Caveats + +- **Context paths.** `ctx.elicit` / `ctx.elicit_url` and the 2-hop + `ctx.request_context.session.send_elicit_complete` are interim; a later + release will shorten these. +- **No per-mode opt-in.** Supplying any `elicitation_callback` advertises both + form and url support; there is currently no way to advertise form-only from + `Client`. +- **Throw-style URL elicitation** (`raise UrlElicitationRequiredError([...])` → + wire `-32042`) is the stateless-transport alternative to `ctx.elicit_url`; + see `tests/interaction/lowlevel/test_elicitation.py` and the `error_handling` + story. + +## Spec + +[Elicitation — client features](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) + +## See also + +`sampling/` (same push-request shape, deprecated per SEP-2577), `mrtr/` +(the 2026-era carrier), `error_handling/` +(`UrlElicitationRequiredError`), `refund_desk/` (resolver DI rides this push +mechanism on handshake-era connections). diff --git a/examples/stories/legacy_elicitation/__init__.py b/examples/stories/legacy_elicitation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/legacy_elicitation/client.py b/examples/stories/legacy_elicitation/client.py new file mode 100644 index 0000000..52bb95e --- /dev/null +++ b/examples/stories/legacy_elicitation/client.py @@ -0,0 +1,32 @@ +"""Auto-answer form and URL elicitations and assert the tool result reflects them.""" + +import mcp_types as types + +from mcp.client import Client, ClientRequestContext +from stories._harness import Target, run_client + + +async def on_elicit(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult: + if isinstance(params, types.ElicitRequestURLParams): + # A real client would ask consent and open params.url in a browser, returning + # `accept` right away; the server's notifications/elicitation/complete arrives + # afterward (once the out-of-band flow finishes) for the client to correlate. + assert params.url.startswith("https://example.com/") + return types.ElicitResult(action="accept") + assert "username" in params.requested_schema["properties"] + return types.ElicitResult(action="accept", content={"username": "alice", "plan": "pro"}) + + +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode, elicitation_callback=on_elicit) as client: + registered = await client.call_tool("register_user", {}) + assert isinstance(registered.content[0], types.TextContent) + assert registered.content[0].text == "registered alice (plan: pro)", registered + + linked = await client.call_tool("link_account", {"provider": "github"}) + assert isinstance(linked.content[0], types.TextContent) + assert linked.content[0].text == "linked github", linked + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/legacy_elicitation/server.py b/examples/stories/legacy_elicitation/server.py new file mode 100644 index 0000000..d2a6e95 --- /dev/null +++ b/examples/stories/legacy_elicitation/server.py @@ -0,0 +1,47 @@ +"""Elicitation (handshake-era push style): a tool blocks on user input mid-call.""" + +from pydantic import BaseModel + +from mcp.server.elicitation import AcceptedElicitation +from mcp.server.mcpserver import Context, MCPServer +from stories._hosting import run_server_from_args + + +class Registration(BaseModel): + username: str + plan: str | None = None + + +def build_server() -> MCPServer: + mcp = MCPServer("legacy-elicitation-example") + + @mcp.tool(description="Register a new account by asking the user for their details.") + async def register_user(ctx: Context) -> str: + answer = await ctx.elicit("Please provide your registration details:", Registration) + if not isinstance(answer, AcceptedElicitation): + return f"registration {answer.action}" + return f"registered {answer.data.username} (plan: {answer.data.plan or 'free'})" + + @mcp.tool(description="Link a third-party account by directing the user to a sign-in URL.") + async def link_account(provider: str, ctx: Context) -> str: + # elicitation_id must be unique per elicitation, not per provider — scope it to this request. + elicitation_id = f"link-{provider}-{ctx.request_context.request_id}" + answer = await ctx.elicit_url( + f"Sign in to {provider} to link your account", + url=f"https://example.com/oauth/{provider}/authorize", + elicitation_id=elicitation_id, + ) + if answer.action != "accept": + return f"link {answer.action}" + # Out-of-band flow finished: tell the client which elicitation completed. + # The 2-hop `ctx.request_context.*` reach is interim; a later release shortens it. + await ctx.request_context.session.send_elicit_complete( + elicitation_id, related_request_id=ctx.request_context.request_id + ) + return f"linked {provider}" + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/legacy_elicitation/server_lowlevel.py b/examples/stories/legacy_elicitation/server_lowlevel.py new file mode 100644 index 0000000..08c7c3a --- /dev/null +++ b/examples/stories/legacy_elicitation/server_lowlevel.py @@ -0,0 +1,70 @@ +"""Elicitation (handshake-era push style) against the low-level Server.""" + +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + +REGISTRATION_SCHEMA: types.ElicitRequestedSchema = { + "type": "object", + "properties": { + "username": {"type": "string"}, + "plan": {"type": "string", "enum": ["free", "pro", "team"]}, + }, + "required": ["username"], +} +LINK_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"provider": {"type": "string"}}, + "required": ["provider"], +} + + +def build_server() -> Server[Any]: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="register_user", description="Register a new account.", input_schema={"type": "object"} + ), + types.Tool( + name="link_account", description="Link a third-party account.", input_schema=LINK_INPUT_SCHEMA + ), + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + if params.name == "register_user": + answer = await ctx.session.elicit_form( + "Please provide your registration details:", REGISTRATION_SCHEMA, related_request_id=ctx.request_id + ) + if answer.action != "accept" or answer.content is None: + return types.CallToolResult(content=[types.TextContent(text=f"registration {answer.action}")]) + text = f"registered {answer.content['username']} (plan: {answer.content.get('plan') or 'free'})" + return types.CallToolResult(content=[types.TextContent(text=text)]) + + assert params.name == "link_account" and params.arguments is not None + provider = params.arguments["provider"] + # elicitation_id must be unique per elicitation, not per provider — scope it to this request. + elicitation_id = f"link-{provider}-{ctx.request_id}" + answer = await ctx.session.elicit_url( + f"Sign in to {provider} to link your account", + url=f"https://example.com/oauth/{provider}/authorize", + elicitation_id=elicitation_id, + related_request_id=ctx.request_id, + ) + if answer.action != "accept": + return types.CallToolResult(content=[types.TextContent(text=f"link {answer.action}")]) + await ctx.session.send_elicit_complete(elicitation_id, related_request_id=ctx.request_id) + return types.CallToolResult(content=[types.TextContent(text=f"linked {provider}")]) + + return Server("legacy-elicitation-example", on_list_tools=list_tools, on_call_tool=call_tool) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/legacy_routing/README.md b/examples/stories/legacy_routing/README.md new file mode 100644 index 0000000..66f839c --- /dev/null +++ b/examples/stories/legacy_routing/README.md @@ -0,0 +1,109 @@ +# legacy-routing + +The exported era classifier. `classify_inbound_request(body, headers=...)` from +`mcp.shared.inbound` is the body-primary test for "is this a 2026-era request?"; +wrap it as `classify_era()` to route eras to different backends in your own +ASGI/ingress layer. Unlike most SDKs, the Python SDK's built-in +`streamable_http_app()` already serves **sessionful** 2025 alongside stateless +2026 on one `/mcp` route — so the predicate is for when you need *different* +arms (per-era auth, separate ports, an existing v1 deployment to keep), not to +make dual-era work at all. + +Also shown: the CORS recipe (methods, request headers, and `expose_headers`) +browser-based MCP clients need. + +## Run it + +```bash +# HTTP only — the predicate is an HTTP-transport concern. The client +# self-hosts the app on a free port, runs, then tears it down. +uv run python -m stories.legacy_routing.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.legacy_routing.client --http --server server_lowlevel + +# against a server you run yourself (real uvicorn on :8000) +uv run python -m stories.legacy_routing.server --port 8000 & +SERVER_PID=$! +uv run python -m stories.legacy_routing.client --http http://127.0.0.1:8000/mcp +kill "$SERVER_PID" +``` + +## What to look at + +- `client.py` — two visible connections to the SAME `/mcp` endpoint from one + `targets()` factory: `Client(targets(), mode=mode)` (default `"auto"` → + `server/discover` → the modern arm) and `Client(targets(), mode="legacy")` + (the `initialize` handshake → the legacy arm). Each asserts `which_arm` + reports the era the built-in router actually dispatched to. The era decision + is one explicit `mode=` argument at construction. +- `client.py` — the predicate then shown directly against a modern body, a + legacy body, and a malformed-modern body. The runnable `build_app()` uses the + SDK's built-in router; the predicate itself is exercised as a pure + function — see the user-land composition recipe below for wiring it into + your own ingress. +- `server.py` `classify_era` — the tri-state wrapper. `InboundModernRoute` → + `"modern"`; rung-1 `INVALID_PARAMS` (no envelope keys) → `"legacy"`; any + other `InboundLadderRejection` is a malformed-modern request to **reject**, + not route to legacy. When headers are supplied, both `Mcp-Protocol-Version` + and `Mcp-Method` must mirror the body — a disagreement (or an unsupported + version) is what produces that third arm; `client.py` shows both. +- `server.py` `build_app` — `streamable_http_app()` + `CORSMiddleware`. The + `which_arm` tool reads `ctx.request_context.protocol_version` to prove which + path the built-in router took. +- `server_lowlevel.py` — the CORS recipe re-used from `server.py` (the + `MCP_*` header and method constants); `build_app` wires `lowlevel.Server` + instead of `MCPServer` and reads `ctx.protocol_version` directly. The + predicate is tier-agnostic, so `classify_era` lives only in `server.py`. + +## User-land composition (when you need different backends) + +There is no `legacy="reject"` flag yet. To route eras to different handlers, +buffer the body, classify, replay: + +```python +async def mcp_endpoint(scope, receive, send): + body, replay = await buffer_body(receive) # your ASGI helper + headers = {k.decode("ascii").lower(): v.decode("latin-1") for k, v in scope["headers"]} + match classify_era(json.loads(body or b"{}"), headers): + case "legacy": + await my_existing_v1_manager.handle_request(scope, replay, send) + case "modern": + await modern_manager.handle_request(scope, replay, send) + case rejection: + await send_jsonrpc_error(send, rejection) # map via ERROR_CODE_HTTP_STATUS +``` + +Non-POST verbs (`GET` standalone-SSE, `DELETE` session termination) are +sessionful-2025-only — route them straight to the legacy arm. + +## Two ports instead of one + +Run two `uvicorn` processes from the same `build_app()` on different ports and +put `classify_era()` (or a header check) in your ingress. Useful when the two +eras need different auth, rate limits, or scaling. + +## Caveats + +- The SDK's **built-in** routing is currently header-only — a 2026 client that + omits `MCP-Protocol-Version` is mis-routed to legacy. + `classify_inbound_request()` is body-primary and is what the built-in moves + to in a later release; user-land routing with the predicate is already + correct today. +- `ctx.request_context.protocol_version` is the interim 2-hop reach; a later + release will shorten it. +- DNS-rebinding protection is on by default; the harness disables it + (`NO_DNS_REBIND`) because the in-process httpx client sends no `Origin`. + Drop the kwarg for a real deployment. +- `mcp.shared.inbound` is a deep import path — a shorter re-export is planned + before beta. + +## Spec + +- [Versioning — backward compatibility](https://modelcontextprotocol.io/specification/draft/basic/versioning) +- [Transports — protocol version header](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports) + +## See also + +`dual_era/` (the simple case: one factory, built-in routing, no predicate), +`stateless_legacy/` (`stateless_http=True`), `starlette_mount/` (mount inside +FastAPI). diff --git a/examples/stories/legacy_routing/__init__.py b/examples/stories/legacy_routing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/legacy_routing/client.py b/examples/stories/legacy_routing/client.py new file mode 100644 index 0000000..b9b401a --- /dev/null +++ b/examples/stories/legacy_routing/client.py @@ -0,0 +1,62 @@ +"""Connect at both eras to one app — so `main` takes `targets` — and assert the built-in router and predicate agree.""" + +from typing import Any + +import mcp_types as types +from mcp_types import CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY +from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION + +from mcp.client import Client +from mcp.shared.inbound import MCP_METHOD_HEADER, MCP_PROTOCOL_VERSION_HEADER, InboundLadderRejection +from stories._harness import TargetFactory, run_client + +from .server import classify_era + + +def _arm(result: types.CallToolResult) -> str: + first = result.content[0] + assert isinstance(first, types.TextContent) + return first.text + + +async def main(targets: TargetFactory, *, mode: str = "auto") -> None: + # ── modern arm: the caller's mode (the real-user "auto" default) probes + # ``server/discover`` → the stateless 2026 path. + async with Client(targets(), mode=mode) as modern: + assert modern.protocol_version == LATEST_MODERN_VERSION + assert _arm(await modern.call_tool("which_arm", {})) == "modern" + + # ── legacy arm: the SAME /mcp endpoint, ``initialize`` handshake → sessionful 2025 path. + async with Client(targets(), mode="legacy") as legacy: + assert legacy.protocol_version == LATEST_HANDSHAKE_VERSION + assert _arm(await legacy.call_tool("which_arm", {})) == "legacy" + + # ── the exported predicate, shown directly. A 2026 _meta envelope whose + # `Mcp-Protocol-Version`/`Mcp-Method` headers mirror it is modern; a bare + # initialize body is legacy; a header that disagrees is a rejection (NOT legacy). + modern_body: dict[str, Any] = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": { + "_meta": { + PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION, + CLIENT_INFO_META_KEY: {"name": "demo", "version": "0"}, + CLIENT_CAPABILITIES_META_KEY: {}, + } + }, + } + modern_headers = {MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION, MCP_METHOD_HEADER: "tools/list"} + assert classify_era(modern_body, headers=modern_headers) == "modern" + + legacy_body: dict[str, Any] = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}} + assert classify_era(legacy_body, headers={}) == "legacy" + + # The SAME complete header set, with only the protocol version disagreeing with the body. + mismatched_headers = modern_headers | {MCP_PROTOCOL_VERSION_HEADER: LATEST_HANDSHAKE_VERSION} + mismatched = classify_era(modern_body, headers=mismatched_headers) + assert isinstance(mismatched, InboundLadderRejection), mismatched + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/legacy_routing/server.py b/examples/stories/legacy_routing/server.py new file mode 100644 index 0000000..79cc2af --- /dev/null +++ b/examples/stories/legacy_routing/server.py @@ -0,0 +1,65 @@ +"""Exported era classifier: the body-primary predicate, the built-in dual-era app, and CORS — exports `build_app()`.""" + +from collections.abc import Mapping +from typing import Any, Literal + +from mcp_types import INVALID_PARAMS +from mcp_types.version import MODERN_PROTOCOL_VERSIONS +from starlette.applications import Starlette +from starlette.middleware.cors import CORSMiddleware + +from mcp.server.mcpserver import Context, MCPServer +from mcp.shared.inbound import InboundLadderRejection, InboundModernRoute, classify_inbound_request +from stories._hosting import NO_DNS_REBIND, run_app_from_args + +#: Response headers a browser-based MCP client must be able to read. +MCP_EXPOSED_HEADERS = ["Mcp-Session-Id", "WWW-Authenticate", "Last-Event-Id", "Mcp-Protocol-Version"] +#: Request headers a browser-based MCP client must be allowed to send. +MCP_ALLOWED_HEADERS = ["Authorization", "Content-Type", "Mcp-Protocol-Version", "Mcp-Session-Id", "Last-Event-Id"] +#: Streamable HTTP verbs: POST requests, the standalone GET stream, DELETE session end. +MCP_ALLOWED_METHODS = ["GET", "POST", "DELETE"] + + +def classify_era( + body: Mapping[str, Any], headers: Mapping[str, str] +) -> Literal["modern", "legacy"] | InboundLadderRejection: + """Tri-state era classifier built on the exported `classify_inbound_request` predicate. + + Compose this in your own ASGI/ingress layer when the two eras need different + backends. Only a rung-1 ``INVALID_PARAMS`` rejection (no envelope keys) means + "treat as legacy"; other rejections are malformed-modern and should be refused. + """ + verdict = classify_inbound_request(body, headers=headers) + if isinstance(verdict, InboundModernRoute): + return "modern" + if verdict.code == INVALID_PARAMS: + return "legacy" + return verdict + + +def build_app() -> Starlette: + mcp = MCPServer("legacy-routing-example") + + @mcp.tool() + async def which_arm(ctx: Context) -> str: + """Report which era the built-in router dispatched this request to.""" + pv = ctx.request_context.protocol_version + return "modern" if pv in MODERN_PROTOCOL_VERSIONS else "legacy" + + # One Starlette app, one /mcp route, both eras: sessionful 2025 (initialize + + # Mcp-Session-Id + GET stream) and stateless 2026 (per-request _meta envelope). + app = mcp.streamable_http_app(transport_security=NO_DNS_REBIND) + + # CORS for browser-based clients. DEMO ONLY — restrict allow_origins in production. + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=MCP_ALLOWED_METHODS, + allow_headers=MCP_ALLOWED_HEADERS, + expose_headers=MCP_EXPOSED_HEADERS, + ) + return app + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/legacy_routing/server_lowlevel.py b/examples/stories/legacy_routing/server_lowlevel.py new file mode 100644 index 0000000..d2f763c --- /dev/null +++ b/examples/stories/legacy_routing/server_lowlevel.py @@ -0,0 +1,48 @@ +"""Exported era classifier (lowlevel API): the same dual-era app + CORS — the predicate stays in `server.py`.""" + +from typing import Any + +import mcp_types as types +from mcp_types.version import MODERN_PROTOCOL_VERSIONS +from starlette.applications import Starlette +from starlette.middleware.cors import CORSMiddleware + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import NO_DNS_REBIND, run_app_from_args + +from .server import MCP_ALLOWED_HEADERS, MCP_ALLOWED_METHODS, MCP_EXPOSED_HEADERS + +WHICH_ARM = types.Tool( + name="which_arm", + description="Report which era the built-in router dispatched this request to.", + input_schema={"type": "object", "properties": {}}, +) + + +def build_app() -> Starlette: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[WHICH_ARM]) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "which_arm" + arm = "modern" if ctx.protocol_version in MODERN_PROTOCOL_VERSIONS else "legacy" + return types.CallToolResult(content=[types.TextContent(text=arm)]) + + server = Server("legacy-routing-example", on_list_tools=list_tools, on_call_tool=call_tool) + + app = server.streamable_http_app(transport_security=NO_DNS_REBIND) + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=MCP_ALLOWED_METHODS, + allow_headers=MCP_ALLOWED_HEADERS, + expose_headers=MCP_EXPOSED_HEADERS, + ) + return app + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/lifespan/README.md b/examples/stories/lifespan/README.md new file mode 100644 index 0000000..f2cb6c9 --- /dev/null +++ b/examples/stories/lifespan/README.md @@ -0,0 +1,53 @@ +# lifespan + +Process-scoped dependency injection. Pass an `@asynccontextmanager` as +`lifespan=` to acquire resources (a database pool, an HTTP client) once at +startup and release them at shutdown; tool bodies read the yielded state via +the injected `Context` — no module-level globals. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.lifespan.client + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.lifespan.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.lifespan.client --http --server server_lowlevel +``` + +## What to look at + +- `client.py` `main` — opens with `Client(target, mode=mode)`; the story owns + the construction, the harness only chooses the target and era. Lifespan is + invisible from here: the client speaks plain MCP, and the `lookup` results + are the only proof the yielded state was wired through. +- `app_lifespan` in `server.py` — the `try / yield / finally` shape is the + startup/shutdown contract; the `finally` block runs once on process exit, not + per request. +- `ctx.request_context.lifespan_context.db` in the `lookup` tool — the interim + 3-hop access path on `MCPServer`'s `Context`. +- `server_lowlevel.py` reaches the same state via `ctx.lifespan_context.db` — + one hop, because lowlevel handlers receive `ServerRequestContext` directly. + +## Caveats + +- `ctx.request_context.lifespan_context` is the interim path; a later release + will shorten this to `ctx.state.*`. The lowlevel `ctx.lifespan_context` path + is unaffected. +- **v1 → v2 scope change** — in v1.x, `lifespan` was entered once per + `Server.run()` call: once per *session* for stateful streamable HTTP and once + per *request* under `stateless_http=True` (stdio was already per-process). In + v2 it is entered once per process regardless of transport. See + `docs/migration.md` ("Streamable HTTP: lifespan now entered once at manager + startup"). + +## Spec + +[Lifecycle](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle) + +## See also + +`stickynotes/` (lifespan-held mutable state with change notifications), +`serve_one/` (threading `lifespan_state` into the kernel by hand). diff --git a/examples/stories/lifespan/__init__.py b/examples/stories/lifespan/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/lifespan/client.py b/examples/stories/lifespan/client.py new file mode 100644 index 0000000..5163317 --- /dev/null +++ b/examples/stories/lifespan/client.py @@ -0,0 +1,22 @@ +"""Prove the lifespan-yielded state is reachable from a tool call.""" + +from mcp_types import TextContent + +from mcp.client import Client +from stories._harness import Target, run_client + + +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + listed = await client.list_tools() + assert [t.name for t in listed.tools] == ["lookup"] + + result = await client.call_tool("lookup", {"key": "alpha"}) + assert isinstance(result.content[0], TextContent) and result.content[0].text == "one", result + + result = await client.call_tool("lookup", {"key": "beta"}) + assert isinstance(result.content[0], TextContent) and result.content[0].text == "two", result + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/lifespan/server.py b/examples/stories/lifespan/server.py new file mode 100644 index 0000000..a66e215 --- /dev/null +++ b/examples/stories/lifespan/server.py @@ -0,0 +1,39 @@ +"""Process-scoped dependency injection via `MCPServer(lifespan=...)`.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass +from typing import Any + +from mcp.server.mcpserver import Context, MCPServer +from stories._hosting import run_server_from_args + + +@dataclass +class AppState: + db: dict[str, str] + + +@asynccontextmanager +async def app_lifespan(server: MCPServer[AppState]) -> AsyncIterator[AppState]: + """Acquire process-scoped resources at startup; release them at shutdown.""" + db = {"alpha": "one", "beta": "two"} # e.g. `await pool.connect()` + try: + yield AppState(db=db) + finally: + db.clear() # e.g. `await pool.disconnect()` + + +def build_server() -> MCPServer[AppState]: + mcp = MCPServer[AppState]("lifespan-example", lifespan=app_lifespan) + + @mcp.tool(description="Look up a key in the process-scoped store.") + def lookup(key: str, ctx: Context[AppState, Any]) -> str: + # Interim 3-hop path; shortens to `ctx.state.db` in a later release. + return ctx.request_context.lifespan_context.db[key] + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/lifespan/server_lowlevel.py b/examples/stories/lifespan/server_lowlevel.py new file mode 100644 index 0000000..09945c1 --- /dev/null +++ b/examples/stories/lifespan/server_lowlevel.py @@ -0,0 +1,66 @@ +"""Process-scoped dependency injection via lowlevel `Server(lifespan=...)`.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + + +@dataclass +class AppState: + db: dict[str, str] + + +@asynccontextmanager +async def app_lifespan(server: Server[AppState]) -> AsyncIterator[AppState]: + db = {"alpha": "one", "beta": "two"} + try: + yield AppState(db=db) + finally: + db.clear() + + +LOOKUP_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"key": {"type": "string"}}, + "required": ["key"], +} + + +def build_server() -> Server[AppState]: + async def list_tools( + ctx: ServerRequestContext[AppState], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="lookup", + description="Look up a key in the process-scoped store.", + input_schema=LOOKUP_INPUT_SCHEMA, + ) + ] + ) + + async def call_tool( + ctx: ServerRequestContext[AppState], params: types.CallToolRequestParams + ) -> types.CallToolResult: + assert params.name == "lookup" and params.arguments is not None + value = ctx.lifespan_context.db[params.arguments["key"]] + return types.CallToolResult(content=[types.TextContent(text=value)]) + + return Server[AppState]( + "lifespan-example", + lifespan=app_lifespan, + on_list_tools=list_tools, + on_call_tool=call_tool, + ) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/manifest.toml b/examples/stories/manifest.toml new file mode 100644 index 0000000..965e04a --- /dev/null +++ b/examples/stories/manifest.toml @@ -0,0 +1,177 @@ +# examples/stories/manifest.toml +# +# test_manifest_matches_filesystem asserts [story.*] keys == story dirs with a client.py. + +[defaults] +transports = ["in-memory", "http-asgi"] # in-memory = Client(server); http-asgi = StreamingASGITransport +era = "dual" # "dual" | "modern" | "legacy" | "dual-in-body" +status = "current" # "current" | "legacy" | "deprecated" — the feature's future, not the transport +lowlevel = true # also run main against server_lowlevel.build_server()/build_app() +server_export = "factory" # "factory" -> build_server() | "app" -> build_app() +multi_connection = false # main(target, ...) vs main(targets, ...); targets() -> fresh target per call +needs_http = false # main(..., http=) gets the raw httpx.AsyncClient (http-asgi only) +timeout_s = 30 +mcp_path = "/mcp" +fixed_port = 0 # `client --http` self-host port; 0 = an OS-assigned free port +xfail = [] # [":", ...] -> strict xfail on that leg +env = {} # env vars set for the leg via monkeypatch + +# ───────────────────────────── start here ───────────────────────────── + +[story.tools] + +[story.prompts] + +[story.resources] + +[story.lifespan] + +[story.dual_era] +era = "dual-in-body" +multi_connection = true + +[story.streaming] + +[story.mrtr] +era = "modern" + +[story.legacy_elicitation] +era = "legacy" +status = "legacy" + +[story.refund_desk] +# Resolver elicitation picks its transport per era: input_required round-trips on +# the modern leg, push elicitation (ctx.elicit) on the legacy one. +lowlevel = false + +[story.sampling] +era = "legacy" +status = "deprecated" + +[story.stickynotes] + +[story.custom_methods] +lowlevel = false + +[story.apps] +# Extension API is MCPServer-tier (Apps decorators + extensions=[...]); no lowlevel variant. +# The extensions capability map (SEP-2133) rides server/discover, a modern-only path, so +# `main` pins "auto" (legacy initialize cannot carry it) and the leg is http-asgi. +lowlevel = false +transports = ["in-memory", "http-asgi"] +era = "dual-in-body" + +[story.extensions] +# Same constraints as `apps`: MCPServer-tier extension API, capability map rides +# server/discover (modern only), client guards the capability assert by presence. +lowlevel = false +transports = ["in-memory", "http-asgi"] +era = "dual-in-body" + +[story.subscriptions] +# subscriptions/listen exists only on the 2026 wire, so there is no legacy leg. +# The listen request parks for the stream's lifetime; the client ends it by +# cancelling the awaiting scope (the spec's client-side close). +era = "modern" + +[story.schema_validators] + +[story.middleware] +# Lowlevel-only: `Server.middleware` is the one public hook (no MCPServer accessor yet). +lowlevel = false + +[story.parallel_calls] +# A per-client fresh target over a real ASGI transport is harness machinery, not user +# code; the same client body works unchanged over HTTP. +transports = ["in-memory"] +multi_connection = true + +[story.roots] +era = "legacy" +status = "deprecated" + +[story.pagination] + +[story.error_handling] + +[story.serve_one] +# Lowlevel-only: the kernel drivers take a `lowlevel.Server`; `MCPServer` has no public +# accessor for its underlying one yet, so there is no MCPServer-tier variant to show. +transports = ["in-memory"] +lowlevel = false + +[story.stateless_legacy] +transports = ["http-asgi"] +server_export = "app" +era = "dual-in-body" +multi_connection = true + +[story.json_response] +transports = ["http-asgi"] +server_export = "app" +era = "modern" +needs_http = true + +[story.legacy_routing] +transports = ["http-asgi"] +server_export = "app" +era = "dual-in-body" +multi_connection = true + +[story.starlette_mount] +transports = ["http-asgi"] +server_export = "app" +lowlevel = false +mcp_path = "/api/" + +[story.sse_polling] +transports = ["http-asgi"] +server_export = "app" +era = "legacy" +status = "legacy" +timeout_s = 20 +# event_store.py is local; example-grade only (sequential IDs, no eviction). + +[story.standalone_get] +transports = ["http-asgi"] +era = "legacy" +status = "legacy" + +[story.reconnect] +transports = ["http-asgi"] +# Both connection modes are pinned inside main itself ("auto" to populate the discover +# cache, then a hard pin + prior_discover=); the leg hands it the real-user default. +era = "dual-in-body" +multi_connection = true + +[story.bearer_auth] +transports = ["http-asgi"] +server_export = "app" +fixed_port = 8000 # issuer/PRM metadata bake in :8000 + +[story.oauth] +transports = ["http-asgi"] +server_export = "app" +multi_connection = true +fixed_port = 8000 # issuer/PRM metadata bake in :8000 +env = { OAUTH_DEMO_AUTO_CONSENT = "1" } + +[story.oauth_client_credentials] +transports = ["http-asgi"] +server_export = "app" +fixed_port = 8000 # issuer/PRM metadata bake in :8000 + +[story.identity_assertion] +transports = ["http-asgi"] +server_export = "app" +fixed_port = 8000 # issuer/PRM metadata bake in :8000 + +# ───────────────────────────── deferred ───────────────────────────── +# README-only placeholders; no client.py, not expanded into legs. +# test_manifest_matches_filesystem checks these match the README-only dirs. + +[deferred] +caching = "client honouring + per-result override unlanded" +tasks = "SEP-2663 — tasks extension runtime (server-decided augmentation, CreateTaskResult)" +skills = "#2896 — SEP-2640" +events = "#2901 + #2896" diff --git a/examples/stories/middleware/README.md b/examples/stories/middleware/README.md new file mode 100644 index 0000000..599f890 --- /dev/null +++ b/examples/stories/middleware/README.md @@ -0,0 +1,56 @@ +# middleware + +Register a single `async (ctx, call_next) -> result` function on +`Server.middleware` to observe or alter every request and notification the +server receives, across both protocol eras and any transport. Middleware sits +*outside* method lookup and params validation, so it sees `initialize`, +`server/discover`, `notifications/*`, and unknown methods too. The chain runs +outermost-first. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.middleware.client + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.middleware.client --http +``` + +## What to look at + +- `client.py` `main` — opens with `async with Client(target, mode=mode)`. The + story owns that construction; the harness only picks the target and era. + Middleware is invisible from this side — only the `audit_log` result proves + the wrap happened. +- `server.py` — `server.middleware.append(record_calls)` is the public + registration point on `mcp.server.lowlevel.Server`. +- `client.py` — the asserted log ends at `"tools/call"` without a `:done` + suffix: `audit_log` runs *inside* `call_next(ctx)`, so the `finally` hasn't + fired yet. That's the wrap. + +## Caveats + +- **Lowlevel-only.** `Server.middleware` on `mcp.server.lowlevel.Server` is the + one public hook; `MCPServer` has no public accessor for it yet (a + `MCPServer.middleware` accessor is planned before beta). +- The middleware signature is **provisional** (see the TODO in + `src/mcp/server/lowlevel/server.py`): it tightens to a covariant `Context[L]` + and gains an outbound seam before v2 final. +- `ServerMiddleware` / `CallNext` / `HandlerResult` are imported from + `mcp.server.context` (helper tier); not re-exported at `mcp.server.lowlevel`. +- Do **not** `await ctx.session.send_request(...)` while wrapping `initialize` + — `initialize` is dispatched inline and the outbound channel isn't open yet. +- To rewrite `ctx.method` / `ctx.params` before the handler runs, pass an + adjusted context through: `await call_next(dataclasses.replace(ctx, ...))`. + `docs/migration.md` shows the full recipe. + +## Spec + +Middleware is SDK architecture, not an MCP spec feature. + +## See also + +`custom_methods/` (a vendor `acme/search` handler registered with +`add_request_handler` — middleware wraps it like any spec method), +`src/mcp/server/_otel.py` (`OpenTelemetryMiddleware`, the SDK's own consumer). diff --git a/examples/stories/middleware/__init__.py b/examples/stories/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/middleware/client.py b/examples/stories/middleware/client.py new file mode 100644 index 0000000..60ebbbc --- /dev/null +++ b/examples/stories/middleware/client.py @@ -0,0 +1,27 @@ +"""Prove the middleware wrapped both `tools/list` and the in-flight `tools/call`.""" + +from mcp.client import Client +from stories._harness import Target, run_client + + +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + listed = await client.list_tools() + assert [t.name for t in listed.tools] == ["audit_log"] + + result = await client.call_tool("audit_log", {}) + assert not result.is_error + assert result.structured_content is not None, result + + # Era-neutral: legacy adds initialize + notifications/initialized; modern HTTP + # adds server/discover; modern in-memory adds nothing. Filter to the methods + # this client drove. + seen = [m for m in result.structured_content["result"] if m.startswith("tools/")] + # The tail ends at tools/call with no :done — the handler ran inside the + # middleware frame. Assert the tail (not the whole list) so a re-run against + # a long-lived server, whose log accumulates across clients, still passes. + assert seen[-3:] == ["tools/list", "tools/list:done", "tools/call"], seen + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/middleware/server.py b/examples/stories/middleware/server.py new file mode 100644 index 0000000..076120d --- /dev/null +++ b/examples/stories/middleware/server.py @@ -0,0 +1,54 @@ +"""Dispatch-layer middleware: `Server.middleware` is the public hook. + +A lowlevel-only story: `MCPServer` has no public middleware accessor yet, so the +one supported registration point is the `middleware` list on `lowlevel.Server`. +""" + +import json +from typing import Any + +import mcp_types as types + +from mcp.server.context import CallNext, HandlerResult, ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + + +def build_server() -> Server[Any]: + log: list[str] = [] + + async def record_calls(ctx: ServerRequestContext[Any], call_next: CallNext) -> HandlerResult: + log.append(ctx.method) + try: + return await call_next(ctx) + finally: + log.append(f"{ctx.method}:done") + + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="audit_log", + description="Return every method the middleware has observed so far.", + input_schema={"type": "object"}, + ) + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "audit_log" + snapshot = list(log) + return types.CallToolResult( + content=[types.TextContent(text=json.dumps(snapshot))], + structured_content={"result": snapshot}, + ) + + server = Server("middleware-example", on_list_tools=list_tools, on_call_tool=call_tool) + server.middleware.append(record_calls) + return server + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/mrtr/README.md b/examples/stories/mrtr/README.md new file mode 100644 index 0000000..870db7d --- /dev/null +++ b/examples/stories/mrtr/README.md @@ -0,0 +1,79 @@ +# mrtr + +Multi-round tool result: on the 2026-07-28 protocol a tool that needs user +input mid-call **returns** `resultType: "input_required"` with embedded +`inputRequests` and an opaque `requestState`, instead of pushing a +server-to-client request. The client fulfils the embedded requests and retries the +original `tools/call` carrying `inputResponses` and the echoed `requestState`. +The story shows both the `Client` auto-loop (one `await call_tool`, callbacks +fired transparently) and a manual `client.session` loop (the persistable +form). Because `requestState` round-trips through the client, it also shows +the security surface that protects it: `MCPServer` seals state by default +under a process-local key, handlers keep writing plaintext, and the wire only +ever carries an opaque token. The manual loop tampers with the sealed token to +show what a forged echo gets back. + +## Run it + +```bash +# HTTP: the client self-hosts the server on a free port, runs, then tears it +# down (the InputRequiredResult round-trip is 2026-era only) +uv run python -m stories.mrtr.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.mrtr.client --http --server server_lowlevel +``` + +## What to look at + +- `server.py` `build_server`: no security configuration at all. The default + seals under a key generated at process start, which is right for a + single-process server like this one; a fleet (multi-worker or load-balanced) + shares keys with `request_state_security=RequestStateSecurity(keys=[...])` + so any instance can verify state another minted. +- `server.py` `deploy`: handlers stay plaintext. The first round returns + `InputRequiredResult(input_requests={...}, + request_state="awaiting-confirm")` and the retry asserts + `ctx.request_state == "awaiting-confirm"`. The tool never touches the + crypto; the boundary seals on the way out and unseals the echo on the way + back in. +- `client.py` `main`: the auto-loop is invisible at the call site: + `Client(target, mode=mode, elicitation_callback=on_elicit)` then + `await client.call_tool("deploy", ...)`. The same `on_elicit` callback the + legacy push path uses is dispatched for each embedded `inputRequests` entry. +- `client.py` manual block: `client.session.call_tool(..., + allow_input_required=True)` returns the raw `InputRequiredResult` so + `request_state` can be persisted between rounds. The wire value is an opaque + sealed token, **not** the string the server code wrote. The client asserts + exactly that, then retries with one character of the token flipped and gets + the single frozen error every verification failure maps to: `-32602`, + `"Invalid or expired requestState"`, `{"reason": "invalid_request_state"}`. + The specific reason (tampered tag, expiry, wrong request, wrong principal) + appears only in the server's log, never on the wire. The untampered token + then completes the round normally. +- `server_lowlevel.py`: the lowlevel tier doesn't seal by default; the same + enforcement is one appended middleware: + `server.middleware.append(RequestStateBoundary(RequestStateSecurity.ephemeral(), + default_audience=server.name))`. + +## Caveats + +- **Loop bound.** The auto-loop gives up after `input_required_max_rounds` + (default 10) with `InputRequiredRoundsExceededError`; raise it on the + `Client` ctor or drop to the manual loop. +- **The default key dies with the process.** It is generated at startup and + held only in memory, so a server restart (or a retry landing on a different + instance) invalidates in-flight rounds: the client gets the same frozen + rejection and must start the flow over. Use + `RequestStateSecurity(keys=[...])` when state must survive either. + +## Spec + +[Input required tool results (server features)](https://modelcontextprotocol.io/specification/draft/server/tools#input-required-tool-results), +[Multi-round-trip requests (security patterns)](https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr) + +## See also + +`legacy_elicitation/` and `sampling/`: the handshake-era push equivalents this +mechanism replaces on the 2026 protocol. `refund_desk/`: resolver DI at the +MCPServer tier: the questions a tool can declare instead of pushing by hand +(its elicited answers ride in the same sealed `requestState`). diff --git a/examples/stories/mrtr/__init__.py b/examples/stories/mrtr/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/mrtr/client.py b/examples/stories/mrtr/client.py new file mode 100644 index 0000000..7280fd0 --- /dev/null +++ b/examples/stories/mrtr/client.py @@ -0,0 +1,70 @@ +"""Drive the deploy tool both ways: the Client auto-loop, and a manual session-level loop.""" + +import mcp_types as types + +from mcp import MCPError +from mcp.client import Client, ClientRequestContext +from stories._harness import Target, run_client + + +async def on_elicit(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult: + # The same callback serves legacy push-style elicitation/create requests AND embedded + # InputRequiredResult.input_requests entries — the driver dispatches both here. + assert isinstance(params, types.ElicitRequestFormParams) + assert "confirm" in params.requested_schema["properties"] + return types.ElicitResult(action="accept", content={"confirm": True}) + + +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode, elicitation_callback=on_elicit) as client: + # ── auto-loop: Client.call_tool dispatches input_requests to on_elicit and retries + # internally; the caller just sees the final CallToolResult. + deployed = await client.call_tool("deploy", {"env": "production"}) + assert isinstance(deployed.content[0], types.TextContent) + assert deployed.content[0].text == "deployed to production", deployed + + # ── manual loop: drop to client.session for the raw InputRequiredResult so the + # request_state can be persisted between rounds (e.g. across a process restart). + first = await client.session.call_tool("deploy", {"env": "staging"}, allow_input_required=True) + assert isinstance(first, types.InputRequiredResult) + assert first.input_requests is not None and "confirm" in first.input_requests + # The boundary sealed server.py's plaintext "awaiting-confirm"; the wire token is opaque. + token = first.request_state + assert token is not None and token != "awaiting-confirm", token + + responses: types.InputResponses = {"confirm": types.ElicitResult(action="decline")} + + # Tamper demo: flipping any one character fails verification, and every failure + # maps to one frozen wire error; the real reason appears only in the server log. + i = len(token) // 2 + tampered = token[:i] + ("A" if token[i] != "A" else "B") + token[i + 1 :] + try: + await client.session.call_tool( + "deploy", + {"env": "staging"}, + input_responses=responses, + request_state=tampered, + allow_input_required=True, + ) + except MCPError as e: + assert e.code == types.INVALID_PARAMS + assert e.message == "Invalid or expired requestState" + assert e.data == {"reason": "invalid_request_state"} + else: + raise AssertionError("expected MCPError for a tampered requestState") + + # The untampered token still completes the round; decline so this path diverges from the auto run. + second = await client.session.call_tool( + "deploy", + {"env": "staging"}, + input_responses=responses, + request_state=token, + allow_input_required=True, + ) + assert isinstance(second, types.CallToolResult) + assert isinstance(second.content[0], types.TextContent) + assert second.content[0].text == "deployment to staging cancelled", second + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/mrtr/server.py b/examples/stories/mrtr/server.py new file mode 100644 index 0000000..8155b90 --- /dev/null +++ b/examples/stories/mrtr/server.py @@ -0,0 +1,39 @@ +"""Multi-round tool result (2026 era): a tool returns input_required and resumes from echoed state.""" + +from mcp_types import ElicitRequest, ElicitRequestedSchema, ElicitRequestFormParams, ElicitResult, InputRequiredResult + +from mcp.server.mcpserver import Context, MCPServer +from stories._hosting import run_server_from_args + +CONFIRM_SCHEMA: ElicitRequestedSchema = { + "type": "object", + "properties": {"confirm": {"type": "boolean", "description": "Proceed with the deployment?"}}, + "required": ["confirm"], +} + + +def build_server() -> MCPServer: + # requestState is sealed by default under a process-local key, which suits this + # single-process server; fleets share keys=[...] so any instance can verify. + mcp = MCPServer("mrtr-example") + + @mcp.tool(description="Deploy to an environment, asking the user to confirm first.") + async def deploy(env: str, ctx: Context) -> str | InputRequiredResult: + responses = ctx.input_responses + if responses is None or "confirm" not in responses: + ask = ElicitRequest( + params=ElicitRequestFormParams(message=f"Deploy to {env}?", requested_schema=CONFIRM_SCHEMA) + ) + # The boundary seals this plaintext request_state on the way out and unseals the echo on retry. + return InputRequiredResult(input_requests={"confirm": ask}, request_state="awaiting-confirm") + assert ctx.request_state == "awaiting-confirm", ctx.request_state + answer = responses["confirm"] + if isinstance(answer, ElicitResult) and answer.action == "accept" and (answer.content or {}).get("confirm"): + return f"deployed to {env}" + return f"deployment to {env} cancelled" + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/mrtr/server_lowlevel.py b/examples/stories/mrtr/server_lowlevel.py new file mode 100644 index 0000000..6f3f489 --- /dev/null +++ b/examples/stories/mrtr/server_lowlevel.py @@ -0,0 +1,67 @@ +"""Multi-round tool result (2026 era) against the low-level Server.""" + +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from mcp.server.request_state import RequestStateBoundary, RequestStateSecurity +from stories._hosting import run_server_from_args + +CONFIRM_SCHEMA: types.ElicitRequestedSchema = { + "type": "object", + "properties": {"confirm": {"type": "boolean", "description": "Proceed with the deployment?"}}, + "required": ["confirm"], +} +DEPLOY_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"env": {"type": "string"}}, + "required": ["env"], +} + + +def build_server() -> Server[Any]: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="deploy", + description="Deploy to an environment, asking the user to confirm first.", + input_schema=DEPLOY_INPUT_SCHEMA, + ) + ] + ) + + async def call_tool( + ctx: ServerRequestContext[Any], params: types.CallToolRequestParams + ) -> types.CallToolResult | types.InputRequiredResult: + assert params.name == "deploy" and params.arguments is not None + env = params.arguments["env"] + responses = params.input_responses + if responses is None or "confirm" not in responses: + ask = types.ElicitRequest( + params=types.ElicitRequestFormParams(message=f"Deploy to {env}?", requested_schema=CONFIRM_SCHEMA) + ) + return types.InputRequiredResult(input_requests={"confirm": ask}, request_state="awaiting-confirm") + assert params.request_state == "awaiting-confirm", params.request_state + answer = responses["confirm"] + if ( + isinstance(answer, types.ElicitResult) + and answer.action == "accept" + and (answer.content or {}).get("confirm") + ): + return types.CallToolResult(content=[types.TextContent(text=f"deployed to {env}")]) + return types.CallToolResult(content=[types.TextContent(text=f"deployment to {env} cancelled")]) + + server = Server("mrtr-example", on_list_tools=list_tools, on_call_tool=call_tool) + # Lowlevel opt-in: append the same boundary middleware MCPServer installs by + # default; the server name becomes the token audience. + server.middleware.append(RequestStateBoundary(RequestStateSecurity.ephemeral(), default_audience=server.name)) + return server + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/oauth/README.md b/examples/stories/oauth/README.md new file mode 100644 index 0000000..a773a78 --- /dev/null +++ b/examples/stories/oauth/README.md @@ -0,0 +1,92 @@ +# oauth + +The full OAuth 2.1 authorization-code flow against an in-process Authorization +Server, over Streamable HTTP. On the **server** side: one `MCPServer(auth=..., +auth_server_provider=...)` constructor call co-hosts the RFC 9728 +protected-resource metadata route, the AS routes (`/register`, `/authorize`, +`/token`, `/.well-known/oauth-authorization-server`) and the bearer-gated +`/mcp` endpoint on a single Starlette app. On the **client** side: +`OAuthClientProvider` is an `httpx.Auth` that reacts to the first `401` by +walking PRM discovery → AS metadata → DCR → PKCE authorize → token exchange → +bearer retry — all inside the first awaited request, with no user-visible +`UnauthorizedError`. + +## Run it + +```bash +# HTTP — the client self-hosts the co-hosted AS + bearer-gated /mcp, runs the +# authorization-code flow (headless: redirect followed in-process), then tears +# it down. Self-hosting uses this story's fixed :8000 (the AS metadata pins +# it), so :8000 must be free. +OAUTH_DEMO_AUTO_CONSENT=1 uv run python -m stories.oauth.client --http +# same, against the lowlevel-API server variant +OAUTH_DEMO_AUTO_CONSENT=1 uv run python -m stories.oauth.client --http --server server_lowlevel + +# against a server you run yourself (real uvicorn on :8000) +OAUTH_DEMO_AUTO_CONSENT=1 uv run python -m stories.oauth.server --port 8000 & +SERVER_PID=$! +uv run python -m stories.oauth.client --http http://127.0.0.1:8000/mcp +kill "$SERVER_PID" +``` + +The port must be **8000**: the demo AS metadata (`_shared/auth.py` `BASE_URL`) +is pinned to it on both the client and server side, so on any other port the +PRM/AS discovery chain points at the wrong origin. + +`OAUTH_DEMO_AUTO_CONSENT=1` makes the demo AS skip the consent screen and 302 +straight back with `?code=...`; without it the authorize step returns +`error=interaction_required` so you can see where a real browser would open. + +`Client(url)` has no `auth=` passthrough, so a target built from a bare URL +can't carry the flow. Both runners close that gap the same way: `run_client` +(above) and the pytest harness build an authed `httpx.AsyncClient` from +this module's `build_auth` export and hand `main` targets that are already +routed through it. + +## What to look at + +- **`client.py` — `Client(targets(), mode=mode)`, twice.** The target `main` + receives is already authed. The first construction is where the whole flow + happens: the first request `401`s and `OAuthClientProvider` runs PRM + discovery → AS metadata → DCR → PKCE authorize → token exchange → bearer + retry before `whoami`'s result reaches the body. +- **`client.py` — the second `Client(targets(), mode=mode)`.** A `Client` + cannot be re-entered after `__aexit__`; reconnecting means constructing a new + one. The provider's `TokenStorage` persisted the tokens and the DCR + registration, so this one sends `Authorization: Bearer ...` on its very first + request — no second `/authorize`, no second `/register`. The demo AS mints a + fresh `client_id` per DCR call, so `whoami` returning the *same* `client_id` + is the reuse proof. +- **`client.py` — `build_auth()`.** `OAuthClientProvider` is an `httpx.Auth`. + `Client(url, auth=...)` is the ergonomic the SDK is missing; until it lands + the auth has to be threaded onto the underlying `httpx.AsyncClient` by hand. +- **`server.py` — `MCPServer(auth=..., auth_server_provider=...)`.** The + constructor wires everything; `streamable_http_app()` reads it back. (Don't + also pass `token_verifier=` — `auth_server_provider` and `token_verifier` are + mutually exclusive.) The `whoami` tool reads the validated principal via + `get_access_token()` — a per-HTTP-request contextvar set by + `AuthContextMiddleware`, not per-session. +- **`server_lowlevel.py`** — same wire shape, but `lowlevel.Server` takes + `auth=`/`token_verifier=`/`auth_server_provider=` on `streamable_http_app()` + rather than the constructor. `mcp.server.auth.*` is a helper tier the lowlevel + API may import directly. + +## Caveats + +- `transport_security=NO_DNS_REBIND` — DNS-rebinding protection is on by default + and the in-process httpx bridge sends no `Origin` header. Drop the kwarg for a + real deployment. +- `HeadlessOAuth` only works because the demo AS auto-consents; a real + `redirect_handler` would open a browser and a real `callback_handler` would + run a loopback HTTP listener for the redirect. +- The `mcp.server.auth.*` import paths are deep (no `mcp.server` re-export yet). + +## Spec + +[Authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) + +## See also + +`bearer_auth/` (RS-only, static token, no AS) · `oauth_client_credentials/` +(M2M `client_credentials` grant — no browser, no DCR) · `reconnect/` (the other +multi-connection `targets()` consumer, no auth). diff --git a/examples/stories/oauth/__init__.py b/examples/stories/oauth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/oauth/client.py b/examples/stories/oauth/client.py new file mode 100644 index 0000000..c55307f --- /dev/null +++ b/examples/stories/oauth/client.py @@ -0,0 +1,61 @@ +"""HTTP-only OAuth authorization-code flow; `build_auth` supplies the provider, reconnecting needs `targets`.""" + +import httpx +from pydantic import AnyUrl + +from mcp.client import Client +from mcp.client.auth import OAuthClientProvider +from mcp.shared.auth import OAuthClientMetadata +from stories._harness import TargetFactory, run_client + +# MCP_URL pins the resource to :8000. The demo AS's own metadata (issuer, PRM `resource`) +# is built from the same constant on the server side, so the whole story is bound to that +# port — run the server on 8000 or both halves of the discovery chain point at the wrong origin. +from stories._shared.auth import MCP_URL, REDIRECT_URI, HeadlessOAuth, InMemoryTokenStorage + + +def build_auth(http_client: httpx.AsyncClient) -> httpx.Auth: + """An `OAuthClientProvider` over fresh storage, completing the authorize redirect headlessly. + + `Client(url, auth=...)` doesn't exist yet, so the harness threads this onto the underlying + `httpx.AsyncClient` and every target `main` receives is already routed through it. + """ + headless = HeadlessOAuth() + headless.bind(http_client) + return OAuthClientProvider( + server_url=MCP_URL, + client_metadata=OAuthClientMetadata( + client_name="oauth-story-client", + redirect_uris=[AnyUrl(REDIRECT_URI)], + grant_types=["authorization_code", "refresh_token"], + ), + storage=InMemoryTokenStorage(), + redirect_handler=headless.redirect_handler, + callback_handler=headless.callback_handler, + ) + + +async def main(targets: TargetFactory, *, mode: str = "auto") -> None: + # The target is already authed with build_auth's OAuthClientProvider. The first request to + # hit the wire 401s, and the provider walks PRM discovery → AS metadata → DCR → PKCE + # authorize → token exchange → bearer retry before any result reaches this body. No + # UnauthorizedError ever surfaces. + async with Client(targets(), mode=mode) as client: + first = await client.call_tool("whoami", {}) + assert first.structured_content is not None + assert "mcp" in first.structured_content["scopes"], first + registered_id = first.structured_content["client_id"] + + # A Client cannot be re-entered after __aexit__; reconnecting means constructing a new one. + # The provider's TokenStorage persisted both the issued tokens and the DCR registration, so + # this connection sends `Authorization: Bearer ...` on its very first request — no second + # /authorize, no second /register. The demo AS mints a fresh client_id per DCR call, so the + # same principal coming back IS the reuse proof. + async with Client(targets(), mode=mode) as reconnected: + again = await reconnected.call_tool("whoami", {}) + assert again.structured_content is not None + assert again.structured_content["client_id"] == registered_id, again + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/oauth/server.py b/examples/stories/oauth/server.py new file mode 100644 index 0000000..6d4c706 --- /dev/null +++ b/examples/stories/oauth/server.py @@ -0,0 +1,40 @@ +"""OAuth-protected MCP server: in-process AS + PRM + bearer-gated /mcp on one Starlette app — exports `build_app()`.""" + +from pydantic import BaseModel +from starlette.applications import Starlette + +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.mcpserver import MCPServer +from stories._hosting import NO_DNS_REBIND, run_app_from_args +from stories._shared.auth import InMemoryAuthorizationServerProvider, auth_settings + + +class Principal(BaseModel): + client_id: str + scopes: list[str] + + +def build_app() -> Starlette: + # The provider is both the Authorization Server (DCR/authorize/token) and the + # token store the bearer middleware validates against — one in-memory dict. + provider = InMemoryAuthorizationServerProvider() + + # ``auth_server_provider=`` alone is enough — MCPServer derives a token verifier + # from it (passing both trips the mutex guard). + mcp = MCPServer( + "oauth-example", + auth=auth_settings(required_scopes=["mcp"]), + auth_server_provider=provider, + ) + + @mcp.tool(description="Return the authenticated principal's client_id and granted scopes.") + def whoami() -> Principal: + token = get_access_token() + assert token is not None + return Principal(client_id=token.client_id, scopes=token.scopes) + + return mcp.streamable_http_app(transport_security=NO_DNS_REBIND) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/oauth/server_lowlevel.py b/examples/stories/oauth/server_lowlevel.py new file mode 100644 index 0000000..0bc7799 --- /dev/null +++ b/examples/stories/oauth/server_lowlevel.py @@ -0,0 +1,58 @@ +"""OAuth-protected MCP server (lowlevel API): same app shape, hand-built result types.""" + +from typing import Any + +import mcp_types as types +from starlette.applications import Starlette + +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.provider import ProviderTokenVerifier +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import NO_DNS_REBIND, run_app_from_args +from stories._shared.auth import InMemoryAuthorizationServerProvider, auth_settings + +WHOAMI_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"client_id": {"type": "string"}, "scopes": {"type": "array", "items": {"type": "string"}}}, + "required": ["client_id", "scopes"], +} + + +def build_app() -> Starlette: + provider = InMemoryAuthorizationServerProvider() + + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="whoami", + description="Return the authenticated principal's client_id and granted scopes.", + input_schema={"type": "object"}, + output_schema=WHOAMI_OUTPUT_SCHEMA, + ), + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "whoami" + token = get_access_token() + assert token is not None + payload = {"client_id": token.client_id, "scopes": token.scopes} + return types.CallToolResult(content=[types.TextContent(text=token.client_id)], structured_content=payload) + + server = Server("oauth-example", on_list_tools=list_tools, on_call_tool=call_tool) + # Unlike MCPServer (auth on the constructor), lowlevel.Server takes auth as + # streamable_http_app() kwargs — same wired routes, different entry point. + return server.streamable_http_app( + auth=auth_settings(required_scopes=["mcp"]), + token_verifier=ProviderTokenVerifier(provider), + auth_server_provider=provider, + transport_security=NO_DNS_REBIND, + ) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/oauth_client_credentials/README.md b/examples/stories/oauth_client_credentials/README.md new file mode 100644 index 0000000..8cd5a5b --- /dev/null +++ b/examples/stories/oauth_client_credentials/README.md @@ -0,0 +1,78 @@ +# oauth-client-credentials + +OAuth 2.0 **`client_credentials`** grant — machine-to-machine MCP auth, no +browser. A backend service authenticates *as itself* by presenting a +pre-registered `client_id`/`client_secret` directly to the AS token endpoint; +the SDK's `ClientCredentialsOAuthProvider` handles 401-challenge → PRM/AS +discovery → token POST → Bearer attachment automatically. + +## Run it + +```bash +# HTTP — the client self-hosts the server, runs the grant, then tears it down. +# Self-hosting uses this story's fixed :8000 (the AS metadata pins it), so +# :8000 must be free. +uv run python -m stories.oauth_client_credentials.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.oauth_client_credentials.client --http --server server_lowlevel + +# against a server you run yourself (real uvicorn on :8000 — auth is HTTP-only) +uv run python -m stories.oauth_client_credentials.server --port 8000 & +SERVER_PID=$! +uv run python -m stories.oauth_client_credentials.client --http http://127.0.0.1:8000/mcp +kill "$SERVER_PID" +``` + +OAuth is an HTTP-layer concern; stdio servers receive credentials via the +environment per the spec, so there is no stdio leg. The port must be **8000**: +the demo AS metadata (`_shared/auth.py` `BASE_URL`) is pinned to it on both +the client and server side. + +## What to look at + +- `client.py` `main` — opens with `async with Client(target, mode=mode) as + client:` and that's the whole program. `target` is a transport that already + carries the OAuth `httpx.Auth`; the body never touches a token. +- `client.py` `build_auth` — five lines of `ClientCredentialsOAuthProvider` + config is all the caller writes; the SDK does RFC 9728 PRM → + RFC 8414 AS-metadata discovery and token exchange on the first 401. +- `server.py` `token_endpoint` — the *entire* AS for this grant: validate + HTTP-Basic `client_id:client_secret`, mint a token, return RFC 6749 JSON. + The SDK's built-in `auth_server_provider=` only routes + `authorization_code`/`refresh_token`, so M2M servers mount their own `/token`. +- `server.py` `whoami` — `get_access_token()` is how a tool reads the + authenticated principal (`client_id`, `scopes`) from the request context. +- `server_lowlevel.py` — identical auth wiring via + `Server.streamable_http_app(auth=..., token_verifier=..., + custom_starlette_routes=[...])`; only the tool registration differs. + +## Caveats + +- `Client(url, auth=build_auth(http))` is the ergonomic the SDK is missing — + `Client(url)` has no `auth=` passthrough. Until it lands, the authed + `httpx.AsyncClient` → `streamable_http_client(url, http_client=hc)` chain has + to be built *outside* `main` and handed in as `target`; both `run_client` + (the standalone `--http` run) and the test harness do that from the + `build_auth` export. +- `transport_security=NO_DNS_REBIND` — DNS-rebinding protection is on by + default for localhost binds; the harness disables it because the in-process + httpx client sends no `Origin` header. Drop the kwarg for a real deployment. +- `OAuthMetadata.authorization_endpoint` is a required field even though a + `client_credentials`-only AS has no authorize endpoint; the server sets a + dummy URL. + +## `private_key_jwt` + +Swap `ClientCredentialsOAuthProvider` for `PrivateKeyJWTOAuthProvider` to +authenticate the token request with a signed assertion (RFC 7523 §2.2) instead +of a shared secret. Not exercised here because the demo AS only validates +`client_secret_basic`. + +## Spec + +[Authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) + +## See also + +`oauth/` (interactive `authorization_code` + PKCE — user-facing flow) · +`bearer_auth/` (static token, no AS — simplest gating). diff --git a/examples/stories/oauth_client_credentials/__init__.py b/examples/stories/oauth_client_credentials/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/oauth_client_credentials/client.py b/examples/stories/oauth_client_credentials/client.py new file mode 100644 index 0000000..318523e --- /dev/null +++ b/examples/stories/oauth_client_credentials/client.py @@ -0,0 +1,46 @@ +"""HTTP-only: ``build_auth`` returns a ``ClientCredentialsOAuthProvider``; ``whoami`` round-trips client_id + scopes.""" + +import httpx + +from mcp.client import Client +from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider +from stories._harness import Target, run_client + +# MCP_URL pins the resource to :8000, and the server side builds its PRM/AS metadata from +# the same constant — run the server on 8000 or the discovery chain points at the wrong origin. +from stories._shared.auth import MCP_URL, InMemoryTokenStorage + +from .server import DEMO_CLIENT_ID, DEMO_CLIENT_SECRET, DEMO_SCOPE + + +def build_auth(_http: httpx.AsyncClient) -> httpx.Auth: + """The ``httpx.Auth`` for the ``client_credentials`` grant — five lines of provider config. + + The SDK then handles 401 → RFC 9728 PRM → RFC 8414 AS-metadata discovery → token POST → + Bearer attachment automatically. ``Client(url)`` has no ``auth=`` passthrough yet, so the + harness threads this onto the transport's ``httpx.AsyncClient`` and hands ``main`` the + already-authed ``target``. + """ + return ClientCredentialsOAuthProvider( + server_url=MCP_URL, + storage=InMemoryTokenStorage(), + client_id=DEMO_CLIENT_ID, + client_secret=DEMO_CLIENT_SECRET, + scopes=DEMO_SCOPE, + ) + + +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + listed = await client.list_tools() + assert [t.name for t in listed.tools] == ["whoami"] + + result = await client.call_tool("whoami", {}) + assert not result.is_error + assert result.structured_content is not None + assert result.structured_content["client_id"] == DEMO_CLIENT_ID, result + assert DEMO_SCOPE in result.structured_content["scopes"] + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/oauth_client_credentials/server.py b/examples/stories/oauth_client_credentials/server.py new file mode 100644 index 0000000..7e3d910 --- /dev/null +++ b/examples/stories/oauth_client_credentials/server.py @@ -0,0 +1,77 @@ +"""Bearer-gated resource server + a minimal in-process ``client_credentials`` AS, one app; exports ``build_app()``.""" + +import base64 +import secrets + +from pydantic import AnyHttpUrl, BaseModel +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse + +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.provider import AccessToken +from mcp.server.mcpserver import MCPServer +from mcp.shared.auth import OAuthMetadata, OAuthToken +from stories._hosting import NO_DNS_REBIND, run_app_from_args +from stories._shared.auth import BASE_URL, auth_settings + +# DEMO ONLY — never hard-code real credentials. +DEMO_CLIENT_ID = "demo-m2m-client" +DEMO_CLIENT_SECRET = "demo-m2m-secret" +DEMO_SCOPE = "mcp:tools" + + +class Whoami(BaseModel): + client_id: str + scopes: list[str] + + +def build_app() -> Starlette: + issued: dict[str, AccessToken] = {} + + class _Verifier: + async def verify_token(self, token: str) -> AccessToken | None: + return issued.get(token) + + mcp = MCPServer( + "oauth-client-credentials-example", + token_verifier=_Verifier(), + auth=auth_settings(required_scopes=[DEMO_SCOPE]), + ) + + @mcp.tool(description="Return the authenticated client_id and granted scopes.") + def whoami() -> Whoami: + token = get_access_token() + assert token is not None + return Whoami(client_id=token.client_id, scopes=token.scopes) + + @mcp.custom_route("/.well-known/oauth-authorization-server", methods=["GET"]) + async def as_metadata(request: Request) -> JSONResponse: + meta = OAuthMetadata( + issuer=AnyHttpUrl(BASE_URL), + authorization_endpoint=AnyHttpUrl(f"{BASE_URL}/authorize"), # unused; required + token_endpoint=AnyHttpUrl(f"{BASE_URL}/token"), + grant_types_supported=["client_credentials"], + token_endpoint_auth_methods_supported=["client_secret_basic"], + scopes_supported=[DEMO_SCOPE], + ) + return JSONResponse(meta.model_dump(by_alias=True, mode="json", exclude_none=True)) + + @mcp.custom_route("/token", methods=["POST"]) + async def token_endpoint(request: Request) -> JSONResponse: + form = await request.form() + if form.get("grant_type") != "client_credentials": + return JSONResponse({"error": "unsupported_grant_type"}, status_code=400) + creds = base64.b64decode(request.headers.get("authorization", "").removeprefix("Basic ")).decode() + if creds != f"{DEMO_CLIENT_ID}:{DEMO_CLIENT_SECRET}": + return JSONResponse({"error": "invalid_client"}, status_code=401) + access = f"access_{secrets.token_hex(16)}" + issued[access] = AccessToken(token=access, client_id=DEMO_CLIENT_ID, scopes=[DEMO_SCOPE], expires_at=None) + body = OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=DEMO_SCOPE) + return JSONResponse(body.model_dump(exclude_none=True), headers={"cache-control": "no-store"}) + + return mcp.streamable_http_app(transport_security=NO_DNS_REBIND) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/oauth_client_credentials/server_lowlevel.py b/examples/stories/oauth_client_credentials/server_lowlevel.py new file mode 100644 index 0000000..ba2003d --- /dev/null +++ b/examples/stories/oauth_client_credentials/server_lowlevel.py @@ -0,0 +1,82 @@ +"""Bearer-gated MCP resource server (lowlevel API) + the same minimal ``client_credentials`` AS.""" + +import base64 +import json +import secrets +from typing import Any + +import mcp_types as types +from pydantic import AnyHttpUrl +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Route + +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.provider import AccessToken +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from mcp.shared.auth import OAuthMetadata, OAuthToken +from stories._hosting import NO_DNS_REBIND, run_app_from_args +from stories._shared.auth import BASE_URL, auth_settings + +from .server import DEMO_CLIENT_ID, DEMO_CLIENT_SECRET, DEMO_SCOPE + + +def build_app() -> Starlette: + issued: dict[str, AccessToken] = {} + + class _Verifier: + async def verify_token(self, token: str) -> AccessToken | None: + return issued.get(token) + + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="whoami", input_schema={"type": "object"})]) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "whoami" + token = get_access_token() + assert token is not None + payload = {"client_id": token.client_id, "scopes": token.scopes} + return types.CallToolResult(content=[types.TextContent(text=json.dumps(payload))], structured_content=payload) + + server = Server("oauth-client-credentials-example", on_list_tools=list_tools, on_call_tool=call_tool) + + async def as_metadata(request: Request) -> JSONResponse: + meta = OAuthMetadata( + issuer=AnyHttpUrl(BASE_URL), + authorization_endpoint=AnyHttpUrl(f"{BASE_URL}/authorize"), # unused; required + token_endpoint=AnyHttpUrl(f"{BASE_URL}/token"), + grant_types_supported=["client_credentials"], + token_endpoint_auth_methods_supported=["client_secret_basic"], + scopes_supported=[DEMO_SCOPE], + ) + return JSONResponse(meta.model_dump(by_alias=True, mode="json", exclude_none=True)) + + async def token_endpoint(request: Request) -> JSONResponse: + form = await request.form() + if form.get("grant_type") != "client_credentials": + return JSONResponse({"error": "unsupported_grant_type"}, status_code=400) + creds = base64.b64decode(request.headers.get("authorization", "").removeprefix("Basic ")).decode() + if creds != f"{DEMO_CLIENT_ID}:{DEMO_CLIENT_SECRET}": + return JSONResponse({"error": "invalid_client"}, status_code=401) + access = f"access_{secrets.token_hex(16)}" + issued[access] = AccessToken(token=access, client_id=DEMO_CLIENT_ID, scopes=[DEMO_SCOPE], expires_at=None) + body = OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=DEMO_SCOPE) + return JSONResponse(body.model_dump(exclude_none=True), headers={"cache-control": "no-store"}) + + return server.streamable_http_app( + auth=auth_settings(required_scopes=[DEMO_SCOPE]), + token_verifier=_Verifier(), + custom_starlette_routes=[ + Route("/.well-known/oauth-authorization-server", as_metadata, methods=["GET"]), + Route("/token", token_endpoint, methods=["POST"]), + ], + transport_security=NO_DNS_REBIND, + ) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/pagination/README.md b/examples/stories/pagination/README.md new file mode 100644 index 0000000..f7113d4 --- /dev/null +++ b/examples/stories/pagination/README.md @@ -0,0 +1,52 @@ +# pagination + +Walk a paginated `resources/list` by hand: feed each result's `next_cursor` +back into `list_resources(cursor=...)` until it is `None`. The cursor is an +opaque server-chosen string — never parse it, and never terminate on a falsy +check (an empty string is a valid cursor under the spec). + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.pagination.client --server server_lowlevel + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.pagination.client --http --server server_lowlevel +``` + +Drop `--server server_lowlevel` (on either transport) to run against the +`MCPServer` variant (single page). + +## What to look at + +- `client.py` `main` — `async with Client(target, mode=mode) as client:` is the + whole connection. The story owns the construction; `target` is whatever + `Client()` accepts (an in-process server, a transport, or an HTTP URL) and + the entry point picks it. +- `client.py` — `if page.next_cursor is None: break`. Termination is + key-absent, not falsy; `while cursor:` would be a spec bug. +- `server_lowlevel.py` — the handler owns the cursor encoding (here: an + integer offset as a string) and rejects an unrecognised cursor with + `-32602 Invalid params`, the spec-recommended response. +- `server.py` — `MCPServer`'s decorator-registered resources are returned in + a single page; the inbound `cursor` is accepted but ignored. The same client + loop still terminates correctly after one request. + +## Caveats + +- **No `iter_*()` helper** — `Client` has no `iter_resources()` / + `iter_tools()` async-iterator yet; the manual `while True` loop shown here + is the supported pattern. +- **MCPServer is single-page** — `MCPServer` ignores `cursor` and never sets + `next_cursor`. Whether it grows a `page_size=` knob or stays single-page by + design is open; use the lowlevel server when you need to emit pages today. + +## Spec + +[Pagination — server utilities](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination) + +## See also + +`resources/`, `tools/`, `prompts/` — every `*/list` method paginates the same +way. Reference test: `tests/interaction/lowlevel/test_pagination.py`. diff --git a/examples/stories/pagination/__init__.py b/examples/stories/pagination/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/pagination/client.py b/examples/stories/pagination/client.py new file mode 100644 index 0000000..a952a32 --- /dev/null +++ b/examples/stories/pagination/client.py @@ -0,0 +1,27 @@ +"""Walk every page of resources/list by hand until next_cursor is absent.""" + +from mcp.client import Client +from stories._harness import Target, run_client + + +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + names: list[str] = [] + cursor: str | None = None + pages_fetched = 0 + while True: + page = await client.list_resources(cursor=cursor) + pages_fetched += 1 + assert pages_fetched <= 6, "server kept returning next_cursor — runaway guard" + names.extend(r.name for r in page.resources) + if page.next_cursor is None: # terminate on absent, NOT on falsy: "" is a valid cursor + break + cursor = page.next_cursor + + assert names == ["alpha", "beta", "gamma", "delta", "epsilon", "zeta"], names + # server_lowlevel.py emits 3 pages of 2; server.py (MCPServer's flat registry) emits 1. + assert pages_fetched in (1, 3), pages_fetched + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/pagination/server.py b/examples/stories/pagination/server.py new file mode 100644 index 0000000..81a4f04 --- /dev/null +++ b/examples/stories/pagination/server.py @@ -0,0 +1,24 @@ +"""Six static resources on MCPServer; its built-in registry serves them as one page.""" + +from mcp.server.mcpserver import MCPServer +from stories._hosting import run_server_from_args + +WORDS = ("alpha", "beta", "gamma", "delta", "epsilon", "zeta") + + +def build_server() -> MCPServer: + mcp = MCPServer("pagination-example") + + def register(word: str) -> None: + @mcp.resource(f"word://{word}", name=word, mime_type="text/plain") + def read() -> str: + return word + + for word in WORDS: + register(word) + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/pagination/server_lowlevel.py b/examples/stories/pagination/server_lowlevel.py new file mode 100644 index 0000000..55958a9 --- /dev/null +++ b/examples/stories/pagination/server_lowlevel.py @@ -0,0 +1,36 @@ +"""Paginated resources/list (lowlevel API): pages of two via an opaque integer-offset cursor.""" + +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from mcp.shared.exceptions import MCPError +from stories._hosting import run_server_from_args + +WORDS = ("alpha", "beta", "gamma", "delta", "epsilon", "zeta") +PAGE_SIZE = 2 + + +def build_server() -> Server[Any]: + async def list_resources( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListResourcesResult: + start = 0 + if params is not None and params.cursor is not None: + if not params.cursor.isdigit() or int(params.cursor) >= len(WORDS): + raise MCPError(code=types.INVALID_PARAMS, message=f"Unknown cursor: {params.cursor!r}") + start = int(params.cursor) + page = WORDS[start : start + PAGE_SIZE] + next_start = start + PAGE_SIZE + return types.ListResourcesResult( + resources=[types.Resource(uri=f"word://{w}", name=w) for w in page], + next_cursor=str(next_start) if next_start < len(WORDS) else None, + ) + + return Server("pagination-example", on_list_resources=list_resources) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/parallel_calls/README.md b/examples/stories/parallel_calls/README.md new file mode 100644 index 0000000..e3b54da --- /dev/null +++ b/examples/stories/parallel_calls/README.md @@ -0,0 +1,60 @@ +# parallel-calls + +Two `Client`s connected to the same server, each with a `call_tool` in flight +at once. The `meet` tool is a rendezvous: a handler signals its own arrival, +then blocks until every named peer has arrived too — so neither call can return +unless the server runs both handlers concurrently. Each caller's +`progress_callback=` sees only the notifications for *its* request — each +`Client` is a separate connection, so there's no shared wire for them to cross +on. + +## Run it + +The tested legs run in-memory (`Client(server)`); the identical `main` body +works unchanged over HTTP — both clients just reach the same server. Under +`--http` the client self-hosts that server on a free port, runs, then tears it +down: + +```bash +# --legacy because handler-emitted progress is dropped on the modern +# streamable-HTTP path today (see Caveats). +uv run python -m stories.parallel_calls.client --http --legacy +# same, against the lowlevel-API server variant +uv run python -m stories.parallel_calls.client --http --legacy --server server_lowlevel +``` + +There is no stdio run for this story: the stdio default spawns a fresh server +subprocess per connection, so two clients there could never rendezvous. + +## What to look at + +- **`client.py` — the two visible `Client(targets(), mode=...)` blocks.** Each + connection is constructed inside `attend(...)`; `targets()` yields a fresh + target on every call and both land on the same server instance. The two + blocks run in one `anyio` task group. +- **`server.py` — the `arrivals` barrier.** Each handler sets its own + `anyio.Event` then waits for every peer's. A server that processed requests + sequentially would never set the second event, so the client would time out — + the timeout *is* the concurrency assertion. No sleeps. +- **`client.py` — `progress_callback=` per call.** Each call passes its own + callback; `received == {"a": ["a"], "b": ["b"]}` shows each connection + delivered its own progress, and — combined with the rendezvous — that both + calls were genuinely in flight at once. +- **`server_lowlevel.py`** — same wire contract on the lowlevel `Server`, + reporting via `ctx.session.report_progress(...)`. + +## Caveats + +- Over Streamable HTTP in the modern (2026-07-28) era, handler-emitted progress + is currently dropped (the single-exchange dispatch context no-ops `notify()`). + In-memory (both eras) and legacy-era HTTP deliver progress correctly — hence + the `--legacy` above. + +## Spec + +[Progress flow](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress) + +## See also + +`streaming/` (progress + cancellation on one call), `reconnect/` (the other +multi-connection client), `tools/` (basics). diff --git a/examples/stories/parallel_calls/__init__.py b/examples/stories/parallel_calls/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/parallel_calls/client.py b/examples/stories/parallel_calls/client.py new file mode 100644 index 0000000..945e541 --- /dev/null +++ b/examples/stories/parallel_calls/client.py @@ -0,0 +1,40 @@ +"""Two concurrent `Client`s, so `main` takes `targets`; their rendezvous in one tool proves concurrent dispatch.""" + +import anyio +from mcp_types import TextContent + +from mcp.client import Client +from stories._harness import TargetFactory, run_client + + +async def main(targets: TargetFactory, *, mode: str = "auto") -> None: + party = ["a", "b"] + results: dict[str, str] = {} + received: dict[str, list[str | None]] = {tag: [] for tag in party} + + async def attend(tag: str) -> None: + async def on_progress(progress: float, total: float | None, message: str | None) -> None: + received[tag].append(message) + + # targets() yields a fresh connection target on every call; both land on the SAME + # server instance, so the two `meet` handlers can observe each other's arrival. + async with Client(targets(), mode=mode) as client: + result = await client.call_tool("meet", {"tag": tag, "party": party}, progress_callback=on_progress) + assert not result.is_error, result + assert isinstance(result.content[0], TextContent) + results[tag] = result.content[0].text + + # Neither call can return until both handlers are running at once; a server that processed + # requests one-at-a-time would never set the second event and we'd time out here. + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: + tg.start_soon(attend, "a") + tg.start_soon(attend, "b") + + assert results == {"a": "a", "b": "b"}, results + # Progress is routed by progress token: each callback saw only its own tag, never the sibling's. + assert received == {"a": ["a"], "b": ["b"]}, received + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/parallel_calls/server.py b/examples/stories/parallel_calls/server.py new file mode 100644 index 0000000..dc6d805 --- /dev/null +++ b/examples/stories/parallel_calls/server.py @@ -0,0 +1,31 @@ +"""One tool that rendezvouses with named peers, proving the server dispatches calls concurrently.""" + +from collections import defaultdict + +import anyio + +from mcp.server.mcpserver import Context, MCPServer +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer("parallel-calls-example") + # One Event per tag, shared across every call to this server instance. A handler sets its + # own tag's event, then waits for every peer's — so no call can return until all named + # peers are concurrently in-flight. A sequential dispatcher would deadlock here. + arrivals: dict[str, anyio.Event] = defaultdict(anyio.Event) + + @mcp.tool() + async def meet(tag: str, party: list[str], ctx: Context) -> str: + """Signal arrival as `tag`, block until every tag in `party` has also arrived, then return.""" + arrivals[tag].set() + for peer in party: + await arrivals[peer].wait() + await ctx.report_progress(1.0, total=1.0, message=tag) + return tag + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/parallel_calls/server_lowlevel.py b/examples/stories/parallel_calls/server_lowlevel.py new file mode 100644 index 0000000..32807e1 --- /dev/null +++ b/examples/stories/parallel_calls/server_lowlevel.py @@ -0,0 +1,48 @@ +"""Rendezvous tool on the lowlevel `Server`, proving concurrent dispatch without `MCPServer`.""" + +from collections import defaultdict +from typing import Any + +import anyio +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + +MEET_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "tag": {"type": "string"}, + "party": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["tag", "party"], +} + + +def build_server() -> Server[Any]: + arrivals: dict[str, anyio.Event] = defaultdict(anyio.Event) + + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[types.Tool(name="meet", description="Rendezvous with peers.", input_schema=MEET_INPUT_SCHEMA)] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "meet" + assert params.arguments is not None + tag = params.arguments["tag"] + assert isinstance(tag, str) + arrivals[tag].set() + for peer in params.arguments["party"]: + await arrivals[peer].wait() + await ctx.session.report_progress(1.0, total=1.0, message=tag) + return types.CallToolResult(content=[types.TextContent(text=tag)]) + + return Server("parallel-calls-example", on_list_tools=list_tools, on_call_tool=call_tool) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/prompts/README.md b/examples/stories/prompts/README.md new file mode 100644 index 0000000..3bce94b --- /dev/null +++ b/examples/stories/prompts/README.md @@ -0,0 +1,50 @@ +# prompts + +Expose prompt templates with `@mcp.prompt()` and let clients autocomplete their +arguments with `@mcp.completion()`. `MCPServer` derives each prompt's +`arguments` (name + required) from the function signature. The client lists +prompts, completes the `language` argument of `code_review`, then renders both +prompts. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.prompts.client + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.prompts.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.prompts.client --http --server server_lowlevel +``` + +## What to look at + +- `client.py` `main` — the body opens with `async with Client(target, + mode=mode) as client:`; `target` is anything `Client(...)` accepts (an + in-process server, a `Transport`, or an HTTP URL). +- `server.py` `greet` vs `code_review` — return a bare `str` (wrapped as one + user message) or a `list[Message]` for a multi-turn seed conversation. +- `server.py` `complete()` — one global handler dispatches on `ref` + + `argument.name`; returning `None` becomes an empty completion. There is no + per-argument `completer=` sugar yet. +- `server_lowlevel.py` — the same `Prompt` / `PromptArgument` descriptors and + `GetPromptResult` built by hand; this is what `MCPServer` generates for you. +- `client.py` `complete(...)` — `argument` is a `{"name": ..., "value": ...}` + dict, the only `Client` request method that takes a raw dict for a typed + wire field. + +## Caveats + +`@mcp.prompt()` and `@mcp.completion()` need the parentheses — `@mcp.prompt` +without `()` raises a confusing `TypeError` at registration time. + +## Spec + +[Prompts](https://modelcontextprotocol.io/specification/2025-11-25/server/prompts) +· [Completion](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion) + +## See also + +`tools/` (start here), `resources/` (the other `ref` kind completion accepts), +`pagination/` (`list_prompts` cursor loop). diff --git a/examples/stories/prompts/__init__.py b/examples/stories/prompts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/prompts/client.py b/examples/stories/prompts/client.py new file mode 100644 index 0000000..22aae4a --- /dev/null +++ b/examples/stories/prompts/client.py @@ -0,0 +1,39 @@ +"""List prompts, autocomplete an argument, then render both prompts.""" + +from mcp_types import PromptReference, TextContent + +from mcp.client import Client +from stories._harness import Target, run_client + + +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + listed = await client.list_prompts() + by_name = {p.name: p for p in listed.prompts} + assert set(by_name) == {"greet", "code_review"} + assert by_name["greet"].arguments is not None + assert [a.name for a in by_name["greet"].arguments] == ["name"] + assert by_name["greet"].arguments[0].required is True + assert by_name["code_review"].title == "Code Review" + + completion = await client.complete( + PromptReference(name="code_review"), + argument={"name": "language", "value": "py"}, + ) + assert completion.completion.values == ["python", "pytorch"], completion + + greeted = await client.get_prompt("greet", {"name": "Ada"}) + assert len(greeted.messages) == 1 + assert greeted.messages[0].role == "user" + assert isinstance(greeted.messages[0].content, TextContent) + assert "Ada" in greeted.messages[0].content.text + + reviewed = await client.get_prompt("code_review", {"language": "rust", "code": "fn main() {}"}) + assert [m.role for m in reviewed.messages] == ["user", "assistant"] + first = reviewed.messages[0].content + assert isinstance(first, TextContent) + assert "rust" in first.text and "fn main() {}" in first.text + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/prompts/server.py b/examples/stories/prompts/server.py new file mode 100644 index 0000000..2ef3fc3 --- /dev/null +++ b/examples/stories/prompts/server.py @@ -0,0 +1,43 @@ +"""Prompts primitive: register templates, list, render, complete an argument.""" + +from mcp_types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference + +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, UserMessage +from stories._hosting import run_server_from_args + +LANGUAGES = ["python", "pytorch", "rust", "go", "typescript"] + + +def build_server() -> MCPServer: + mcp = MCPServer("prompts-example") + + @mcp.prompt(title="Greeting") + def greet(name: str) -> str: + """Ask the model to greet someone by name.""" + return f"Write a one-line greeting for {name}." + + @mcp.prompt(title="Code Review") + def code_review(language: str, code: str) -> list[Message]: + """Ask the model to review a code snippet.""" + return [ + UserMessage(f"Review this {language} code for bugs and idioms:\n\n{code}"), + AssistantMessage("I'll review it. Let me read through the code first."), + ] + + @mcp.completion() + async def complete( + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, + ) -> Completion | None: + if isinstance(ref, PromptReference) and ref.name == "code_review" and argument.name == "language": + matches = [lang for lang in LANGUAGES if lang.startswith(argument.value)] + return Completion(values=matches, total=len(matches), has_more=False) + return None + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/prompts/server_lowlevel.py b/examples/stories/prompts/server_lowlevel.py new file mode 100644 index 0000000..2fb41de --- /dev/null +++ b/examples/stories/prompts/server_lowlevel.py @@ -0,0 +1,87 @@ +"""Prompts primitive (lowlevel API): hand-built Prompt descriptors, GetPromptResult, completion.""" + +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + +LANGUAGES = ["python", "pytorch", "rust", "go", "typescript"] + +PROMPTS = [ + types.Prompt( + name="greet", + title="Greeting", + description="Ask the model to greet someone by name.", + arguments=[types.PromptArgument(name="name", required=True)], + ), + types.Prompt( + name="code_review", + title="Code Review", + description="Ask the model to review a code snippet.", + arguments=[ + types.PromptArgument(name="language", required=True), + types.PromptArgument(name="code", required=True), + ], + ), +] + + +def build_server() -> Server[Any]: + async def list_prompts( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListPromptsResult: + return types.ListPromptsResult(prompts=PROMPTS) + + async def get_prompt(ctx: ServerRequestContext[Any], params: types.GetPromptRequestParams) -> types.GetPromptResult: + args = params.arguments or {} + if params.name == "greet": + return types.GetPromptResult( + description="Ask the model to greet someone by name.", + messages=[ + types.PromptMessage( + role="user", + content=types.TextContent(text=f"Write a one-line greeting for {args['name']}."), + ) + ], + ) + if params.name == "code_review": + return types.GetPromptResult( + description="Ask the model to review a code snippet.", + messages=[ + types.PromptMessage( + role="user", + content=types.TextContent( + text=f"Review this {args['language']} code for bugs and idioms:\n\n{args['code']}" + ), + ), + types.PromptMessage( + role="assistant", + content=types.TextContent(text="I'll review it. Let me read through the code first."), + ), + ], + ) + raise NotImplementedError + + async def completion(ctx: ServerRequestContext[Any], params: types.CompleteRequestParams) -> types.CompleteResult: + if ( + isinstance(params.ref, types.PromptReference) + and params.ref.name == "code_review" + and params.argument.name == "language" + ): + matches = [lang for lang in LANGUAGES if lang.startswith(params.argument.value)] + return types.CompleteResult(completion=types.Completion(values=matches, total=len(matches), has_more=False)) + return types.CompleteResult(completion=types.Completion(values=[])) + + return Server( + "prompts-example", + on_list_prompts=list_prompts, + on_get_prompt=get_prompt, + on_completion=completion, + ) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/reconnect/README.md b/examples/stories/reconnect/README.md new file mode 100644 index 0000000..78d281e --- /dev/null +++ b/examples/stories/reconnect/README.md @@ -0,0 +1,56 @@ +# reconnect + +Probe `server/discover` once, persist the `DiscoverResult`, and reconnect with +**zero round-trips**. The first client connects at `mode="auto"` (one +`server/discover` request inside `__aenter__`); a second client at +`mode=LATEST_MODERN_VERSION, prior_discover=` enters with no wire +traffic and has `server_info` / `server_capabilities` available immediately. + +## Run it + +```bash +# over HTTP — Streamable HTTP only; in-memory has no "round-trip" to skip. +# The client self-hosts the server on a free port, runs, then tears it down. +uv run python -m stories.reconnect.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.reconnect.client --http --server server_lowlevel +``` + +## What to look at + +- `client.py` — the first `Client(targets(), mode="auto")`. The `mode="auto"` + connect ladder runs `server/discover` inside `__aenter__`; + `client.session.discover_result` is the cached result. Round-trip it through + `model_dump_json()` / `DiscoverResult.model_validate_json()` to model an + on-disk cache. +- `client.py` — `Client(targets(), mode=LATEST_MODERN_VERSION, + prior_discover=rehydrated)`. A version pin plus a prior `DiscoverResult` + installs the cached state via `ClientSession.adopt()` with no `initialize` + and no `server/discover` on the wire — the era-neutral `client.server_info` / + `.server_capabilities` accessors are populated before the first request. +- `client.py` — `targets()`. A `Client` cannot be re-entered after exit; each + call yields a fresh target against the same server, so the reconnect is a + genuinely new connection. + +## Caveats + +- `mode=` *without* `prior_discover=` synthesizes a placeholder + whose `server_info` is `Implementation(name="", version="")`. Pass the cached + result to get real identity on reconnect. Whether `Client` should expose a + public synthesizer (or refuse the bare pin) is open. +- `client.session.discover_result` is a one-hop reach into the mechanics layer; + `Client` does not yet surface the cached result directly. +- The wire-level proof that the second entry sends zero requests lives in the + interaction suite (`test_prior_discover_populates_state_with_zero_connect_time_traffic`); + this story asserts only what's observable through the public `Client` + surface. + +## Spec + +- [`server/discover`](https://modelcontextprotocol.io/specification/draft/server/discover) +- [Versioning — backward compatibility](https://modelcontextprotocol.io/specification/draft/basic/versioning) + +## See also + +`dual_era/` (auto-discover + era-neutral accessors), `parallel_calls/` (the +other multi-connection client). diff --git a/examples/stories/reconnect/__init__.py b/examples/stories/reconnect/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/reconnect/client.py b/examples/stories/reconnect/client.py new file mode 100644 index 0000000..aab2312 --- /dev/null +++ b/examples/stories/reconnect/client.py @@ -0,0 +1,44 @@ +"""Probe server/discover once, persist the result, reconnect with zero round-trips — a fresh `Client` via `targets`.""" + +from mcp_types import DiscoverResult +from mcp_types.version import LATEST_MODERN_VERSION + +from mcp.client import Client +from stories._harness import TargetFactory, run_client + + +async def main(targets: TargetFactory, *, mode: str = "auto") -> None: + # The caller's mode (the real-user "auto" default) probes server/discover inside + # __aenter__ and caches the result; a hard version pin would skip the probe and + # never see the server's real DiscoverResult. + async with Client(targets(), mode=mode) as client: + discovered = client.session.discover_result + assert discovered is not None, "mode='auto' against a modern server populates discover_result" + assert client.protocol_version == LATEST_MODERN_VERSION + assert client.server_info.name == "reconnect-example" + assert LATEST_MODERN_VERSION in discovered.supported_versions + + result = await client.call_tool("add", {"a": 2, "b": 3}) + assert result.structured_content == {"result": 5}, result + + # Round-trip through JSON to model loading the result from an on-disk cache. + saved = discovered.model_dump_json(by_alias=True) + rehydrated = DiscoverResult.model_validate_json(saved) + assert rehydrated == discovered + + # Reconnect: a version pin plus the cached DiscoverResult adopts the prior state with + # zero round-trips on entry. A Client cannot be re-entered after exit, so targets() + # yields a fresh one. Without prior_discover= a bare pin would synthesize a blank + # server_info — the cache is what makes the era-neutral accessors useful here. + async with Client(targets(), mode=LATEST_MODERN_VERSION, prior_discover=rehydrated) as second: + assert second.protocol_version == LATEST_MODERN_VERSION + assert second.server_info.name == "reconnect-example" + assert second.server_capabilities.tools is not None + assert second.session.discover_result == rehydrated + + result = await second.call_tool("add", {"a": 1, "b": 1}) + assert result.structured_content == {"result": 2}, result + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/reconnect/server.py b/examples/stories/reconnect/server.py new file mode 100644 index 0000000..bda460a --- /dev/null +++ b/examples/stories/reconnect/server.py @@ -0,0 +1,23 @@ +"""A small modern server whose DiscoverResult a client persists for zero-RTT reconnect.""" + +from mcp.server.mcpserver import MCPServer +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer( + "reconnect-example", + version="1.0.0", + instructions="Call add(a, b) to sum two integers.", + ) + + @mcp.tool() + def add(a: int, b: int) -> int: + """Add two integers.""" + return a + b + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/reconnect/server_lowlevel.py b/examples/stories/reconnect/server_lowlevel.py new file mode 100644 index 0000000..5c6a057 --- /dev/null +++ b/examples/stories/reconnect/server_lowlevel.py @@ -0,0 +1,48 @@ +"""A small modern server whose DiscoverResult a client persists for zero-RTT reconnect (lowlevel API).""" + +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + +ADD = types.Tool( + name="add", + description="Add two integers.", + input_schema={ + "type": "object", + "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}}, + "required": ["a", "b"], + }, +) + + +def build_server() -> Server[Any]: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[ADD]) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.arguments is not None + if params.name == "add": + total = int(params.arguments["a"]) + int(params.arguments["b"]) + return types.CallToolResult( + content=[types.TextContent(text=str(total))], + structured_content={"result": total}, + ) + raise NotImplementedError + + return Server( + "reconnect-example", + version="1.0.0", + instructions="Call add(a, b) to sum two integers.", + on_list_tools=list_tools, + on_call_tool=call_tool, + ) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/refund_desk/README.md b/examples/stories/refund_desk/README.md new file mode 100644 index 0000000..f103636 --- /dev/null +++ b/examples/stories/refund_desk/README.md @@ -0,0 +1,88 @@ +# refund-desk + +Resolver dependency injection: a tool parameter annotated `Annotated[T, +Resolve(fn)]` is filled by running the resolver `fn` before the tool body, +instead of from the LLM-supplied arguments. Here `refund_order(order_id, +reason)` refunds what the order record says — `cents` is resolver-computed and +does not appear in the input schema at all, so the model cannot supply or +inflate the amount. Resolvers form a DAG (`load_order` → `refund_scope` → +`refund_amount` / `ask_restock`), may return `Elicit[...]` to ask the human, +and ask each question at most once per call. A resolver's own plain +parameters are filled from the tool's arguments by name — +`load_order(order_id)` receives the `order_id` the model passed to +`refund_order`. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.refund_desk.client + +# HTTP — the client self-hosts the server on a free port, runs, then tears it +# down (2026 protocol: the questions ride embedded input_required round-trips; +# add --legacy to ride synchronous push elicitation instead) +uv run python -m stories.refund_desk.client --http +``` + +## What to look at + +- `server.py` `refund_order` — the signature is the whole story: `order_id` and + `reason` are model-facing; `cents` and `restock` carry `Resolve(...)` markers + and never reach the input schema. `client.py` asserts `properties` and + `required` are exactly `{order_id, reason}`. At 2026 the resolver's elicited + answers ride between rounds inside a `requestState` the SDK seals by default; + see `mrtr/` for the full security walk-through. +- `server.py` `refund_scope` — the no-round-trip fast path: a one-line order + returns `Scope(full=True)` directly; only a multi-line order returns + `Elicit(...)`. The ORD-7001 call completes with zero elicitations. +- `server.py` `_scoped` — the elicited SKU is human-typed free text; it is + validated against the order (`ToolError` on a miss) before any amount is + computed. +- The decline contrast: `refund_amount` takes `scope` **unwrapped**, so + declining the scope question aborts the whole `cents` chain with an error + containing the framework's + `Resolver for parameter 'scope' could not resolve: elicitation was decline` + (the client sees it behind the usual `Error executing tool refund_order:` + prefix); `restock` keeps the `ElicitationResult` union, so declining restock + still refunds — just with `restocked: false`. +- `client.py` — the scope counter proves memoization from outside: one call + consumes `refund_scope` from two resolvers but the question fires once. + +## Caveats + +- **Transport per era.** The framework picks the elicitation transport from + the negotiated protocol: at >= 2026-07-28 the questions ride embedded + `input_required` round-trips (a resolver that depends on another's answer is + asked in a later round); at <= 2025-11-25 each is a synchronous + `elicitation/create` push request mid-call. Author code is identical on + both — this client runs unchanged on either era. +- **Decline order.** A declined unwrapped dependency aborts resolution in + tool-signature order — `cents` resolves before `restock`, so `ask_restock` + never runs. Don't rely on a later resolver's side effects after an earlier + consumer can abort. +- **Memoization scope.** Each question is asked at most once per call, and + within a round each resolver runs at most once, keyed by function identity. + Across 2026 rounds only *elicited* outcomes persist (in `requestState`); any + resolver's body may run again on each round the call passes through. A + recorded answer is consulted only when the resolver asks its question again: + it satisfies the question without re-prompting the user, and it never stands + in for a value the resolver computes itself. + An answer is matched back to its question when the call resumes, so an + eliciting resolver must derive its question deterministically from the + tool's arguments and earlier answers; a per-call generated value (a + `default_factory` id, a timestamp) is re-derived each round and must not + appear in a question the answer is meant to bind to. Nothing is cached + across calls or connections. +- **Validate elicited values.** Elicited answers are human-typed; check them + against your records (as `_scoped` does) before acting on them. + +## Spec + +[Elicitation — client features](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation), +[Input required tool results — server features](https://modelcontextprotocol.io/specification/draft/server/tools#input-required-tool-results) + +## See also + +`mrtr/` (the 2026 `input_required` carrier these questions ride at +>= 2026-07-28), `legacy_elicitation/` (the push mechanism they ride on +handshake-era connections). diff --git a/examples/stories/refund_desk/__init__.py b/examples/stories/refund_desk/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/refund_desk/client.py b/examples/stories/refund_desk/client.py new file mode 100644 index 0000000..0ff8d28 --- /dev/null +++ b/examples/stories/refund_desk/client.py @@ -0,0 +1,105 @@ +"""Prove the refund amount is schema-hidden, resolvers memoize per call, and decline semantics differ per consumer.""" + +import mcp_types as types + +from mcp.client import Client, ClientRequestContext +from stories._harness import Target, run_client + + +async def main(target: Target, *, mode: str = "auto") -> None: + # Scripted answers + per-topic counters; topics in `declines` are refused. + counts = {"scope": 0, "restock": 0} + answers: dict[str, dict[str, str | int | float | bool | list[str] | None]] = { + "scope": {"full": True}, + "restock": {"restock": True}, + } + declines: set[str] = set() + + async def on_elicit(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult: + assert isinstance(params, types.ElicitRequestFormParams) + topic = "scope" if "full" in params.requested_schema["properties"] else "restock" + counts[topic] += 1 + if topic in declines: + return types.ElicitResult(action="decline") + return types.ElicitResult(action="accept", content=answers[topic]) + + async with Client(target, mode=mode, elicitation_callback=on_elicit) as client: + # The model-facing contract is order_id + reason only; cents and restock are resolver-filled. + listed = await client.list_tools() + (tool,) = listed.tools + assert set(tool.input_schema["properties"]) == {"order_id", "reason"}, tool.input_schema + assert set(tool.input_schema.get("required", ())) == {"order_id", "reason"}, tool.input_schema + + # One digital line: scope auto-fills (full), restock auto-fills (False) — zero round-trips. + receipt = await client.call_tool("refund_order", {"order_id": "ORD-7001", "reason": "download corrupted"}) + assert receipt.structured_content == { + "order_id": "ORD-7001", + "refunded_cents": 1500, + "restocked": False, + "reason": "download corrupted", + }, receipt.structured_content + assert counts == {"scope": 0, "restock": 0}, counts + + # Full refund of a three-line order. The scope question fires exactly ONCE even though + # both refund_amount and ask_restock consume it — asked at most once per call on either + # era. ask_restock needs the scope ANSWER, so at 2026 the two questions land in + # successive rounds, never one concurrent batch: counts and order are era-independent. + receipt = await client.call_tool("refund_order", {"order_id": "ORD-7002", "reason": "arrived broken"}) + assert receipt.structured_content == { + "order_id": "ORD-7002", + "refunded_cents": 4800, + "restocked": True, + "reason": "arrived broken", + }, receipt.structured_content + assert counts == {"scope": 1, "restock": 1}, counts + + # Declining restock still refunds: the tool keeps the ElicitationResult union for + # `restock`, sees the decline, and just skips the restock. The scope counter moves + # again — questions are deduped per call, not per connection. + declines.add("restock") + answers["scope"] = {"full": False, "sku": "canvas-tote"} + receipt = await client.call_tool("refund_order", {"order_id": "ORD-7002", "reason": "wrong colour"}) + assert receipt.structured_content == { + "order_id": "ORD-7002", + "refunded_cents": 2400, + "restocked": False, + "reason": "wrong colour", + }, receipt.structured_content + assert counts == {"scope": 2, "restock": 2}, counts + declines.clear() + + # An elicited SKU is human-typed: the server validates it against the order before + # any money is computed. + answers["scope"] = {"full": False, "sku": "mystery-hat"} + result = await client.call_tool("refund_order", {"order_id": "ORD-7002", "reason": "lost parcel"}) + assert result.is_error, result + assert isinstance(result.content[0], types.TextContent) + assert "order has no item 'mystery-hat'" in result.content[0].text, result.content[0].text + + # Declining scope aborts the whole call: refund_amount and ask_restock both consume scope + # unwrapped, so whichever resolves first (`cents`, in signature order) aborts, and + # ask_restock never runs under any order. + declines.add("scope") + restock_before = counts["restock"] + result = await client.call_tool("refund_order", {"order_id": "ORD-7002", "reason": "changed mind"}) + assert result.is_error, result + assert isinstance(result.content[0], types.TextContent) + assert "Resolver for parameter 'scope' could not resolve: elicitation was decline" in result.content[0].text, ( + result.content[0].text + ) + assert counts["restock"] == restock_before, counts + declines.clear() + + # A ToolError raised inside a resolver surfaces exactly like one from the tool body. + result = await client.call_tool("refund_order", {"order_id": "ORD-9999", "reason": "typo"}) + assert result.is_error, result + assert isinstance(result.content[0], types.TextContent) + assert "unknown order 'ORD-9999'" in result.content[0].text, result.content[0].text + + # Full elicitation trajectory: scope fired in legs 2-5 (memoized within each call), + # restock only in the two calls that reached it. + assert counts == {"scope": 4, "restock": 2}, counts + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/refund_desk/server.py b/examples/stories/refund_desk/server.py new file mode 100644 index 0000000..a263b93 --- /dev/null +++ b/examples/stories/refund_desk/server.py @@ -0,0 +1,127 @@ +"""Resolver DI: the refund amount is computed by resolvers from the order record — `cents` never appears in the +tool's input schema, so the model cannot supply or inflate it.""" + +from dataclasses import dataclass +from typing import Annotated + +from pydantic import BaseModel + +from mcp.server.mcpserver import ( + AcceptedElicitation, + Elicit, + ElicitationResult, + MCPServer, + Resolve, +) +from mcp.server.mcpserver.exceptions import ToolError +from stories._hosting import run_server_from_args + + +@dataclass(frozen=True) +class Line: + sku: str + cents: int + physical: bool + + +@dataclass(frozen=True) +class Order: + order_id: str + lines: tuple[Line, ...] + + +ORDERS: dict[str, Order] = { + "ORD-7001": Order("ORD-7001", (Line("ebook-fieldnotes", 1500, physical=False),)), + "ORD-7002": Order( + "ORD-7002", + ( + Line("enamel-mug", 1800, physical=True), + Line("canvas-tote", 2400, physical=True), + Line("sticker-pack", 600, physical=False), + ), + ), +} + + +class Scope(BaseModel): + """Which items to refund: the whole order, or a single SKU.""" + + full: bool + sku: str = "" + + +class RestockChoice(BaseModel): + restock: bool + + +class Receipt(BaseModel): + order_id: str + refunded_cents: int + restocked: bool + reason: str + + +def load_order(order_id: str) -> Order: + order = ORDERS.get(order_id) + if order is None: + raise ToolError(f"unknown order {order_id!r}") + return order + + +def refund_scope(order_id: str, order: Annotated[Order, Resolve(load_order)]) -> Scope | Elicit[Scope]: + if len(order.lines) == 1: + return Scope(full=True) + skus = ", ".join(line.sku for line in order.lines) + return Elicit(f"{order_id} has several items ({skus}). Refund the whole order, or one SKU?", Scope) + + +def _scoped(order: Order, scope: Scope) -> tuple[Line, ...]: + """The lines a scope covers. The SKU was typed by a human — validate it against the order.""" + if scope.full: + return order.lines + lines = tuple(line for line in order.lines if line.sku == scope.sku) + if not lines: + raise ToolError(f"order has no item {scope.sku!r}") + return lines + + +def refund_amount( + order: Annotated[Order, Resolve(load_order)], + scope: Annotated[Scope, Resolve(refund_scope)], +) -> int: + return sum(line.cents for line in _scoped(order, scope)) + + +def ask_restock( + order: Annotated[Order, Resolve(load_order)], + scope: Annotated[Scope, Resolve(refund_scope)], +) -> RestockChoice | Elicit[RestockChoice]: + physical = [line.sku for line in _scoped(order, scope) if line.physical] + if not physical: + return RestockChoice(restock=False) + return Elicit(f"The refund includes physical items ({', '.join(physical)}). Return them to stock?", RestockChoice) + + +def build_server() -> MCPServer: + # Elicited answers ride between rounds in a requestState the SDK seals by default; + # see mrtr/ for the full security walk-through. + mcp = MCPServer("refund-desk") + + @mcp.tool(description="Refund an order. The amount comes from the order record, not from the caller.") + def refund_order( + order_id: str, + reason: str, + cents: Annotated[int, Resolve(refund_amount)], + restock: Annotated[ElicitationResult[RestockChoice], Resolve(ask_restock)], + ) -> Receipt: + # `restock` keeps the full elicitation outcome: a declined restock still refunds. A plain + # (non-Elicit) resolver return arrives wrapped as an accepted outcome, so the fast path + # lands in the same `AcceptedElicitation` branch. + restocked = isinstance(restock, AcceptedElicitation) and restock.data.restock + return Receipt(order_id=order_id, refunded_cents=cents, restocked=restocked, reason=reason) + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/resources/README.md b/examples/stories/resources/README.md new file mode 100644 index 0000000..10b210f --- /dev/null +++ b/examples/stories/resources/README.md @@ -0,0 +1,50 @@ +# resources + +Expose data by URI: a static resource (`config://app`) and an RFC-6570 +template (`greeting://{name}`). One `@mcp.resource()` decorator handles both — +the SDK infers static-vs-template from whether the URI contains `{...}`. The +client lists resources, lists templates, then reads each. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.resources.client + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.resources.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.resources.client --http --server server_lowlevel +``` + +## What to look at + +- `client.py` `async with Client(target, mode=mode) as client:` — the one line + every client example exists to teach. `target` is anything `Client()` + accepts (an in-process server, a transport, or an HTTP URL) and `mode=` is + always explicit; the rest of the story is the body of that `async with`. +- `server.py` `app_config` vs `greeting` — a URI with no `{}` registers a + static resource (appears in `resources/list`); a URI with `{name}` registers + a template (appears only in `resources/templates/list`) and the placeholder + must match the function parameter name. +- `server_lowlevel.py` `read_resource` — without `MCPServer` you own the URI + dispatch yourself, including raising `MCPError(code=INVALID_PARAMS, ...)` for + unknown URIs (matches what `MCPServer` sends). +- `client.py` `isinstance(entry, TextResourceContents)` — `contents` is a list + of `TextResourceContents | BlobResourceContents`; narrow before reading + `.text`. + +## Not shown here + +Subscriptions. Per-URI `resources/subscribe` is a 2025-era RPC being replaced +by `subscriptions/listen` in 2026-07-28; neither is shown in this story. See +`stickynotes/` for `list_changed` notifications. + +## Spec + +[Resources — server features](https://modelcontextprotocol.io/specification/2025-11-25/server/resources) + +## See also + +`stickynotes/` (list-changed notifications), `pagination/` (cursor over a long +resource list). diff --git a/examples/stories/resources/__init__.py b/examples/stories/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/resources/client.py b/examples/stories/resources/client.py new file mode 100644 index 0000000..29f88d5 --- /dev/null +++ b/examples/stories/resources/client.py @@ -0,0 +1,30 @@ +"""List resources and templates, then read both the static and templated URIs.""" + +from mcp_types import TextResourceContents + +from mcp.client import Client +from stories._harness import Target, run_client + + +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + listed = await client.list_resources() + assert [r.uri for r in listed.resources] == ["config://app"] + + templates = await client.list_resource_templates() + assert [t.uri_template for t in templates.resource_templates] == ["greeting://{name}"] + + config = await client.read_resource("config://app") + entry = config.contents[0] + assert isinstance(entry, TextResourceContents) + assert entry.text == '{"feature": true}' + assert entry.mime_type == "application/json" + + hello = await client.read_resource("greeting://world") + entry = hello.contents[0] + assert isinstance(entry, TextResourceContents) + assert entry.text == "Hello, world!" + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/resources/server.py b/examples/stories/resources/server.py new file mode 100644 index 0000000..0879455 --- /dev/null +++ b/examples/stories/resources/server.py @@ -0,0 +1,24 @@ +"""Resources primitive: a static URI and an RFC-6570 template via @mcp.resource().""" + +from mcp.server.mcpserver import MCPServer +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer("resources-example") + + @mcp.resource("config://app", mime_type="application/json") + def app_config() -> str: + """Static application config.""" + return '{"feature": true}' + + @mcp.resource("greeting://{name}") + def greeting(name: str) -> str: + """A greeting for the named subject.""" + return f"Hello, {name}!" + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/resources/server_lowlevel.py b/examples/stories/resources/server_lowlevel.py new file mode 100644 index 0000000..2161fec --- /dev/null +++ b/examples/stories/resources/server_lowlevel.py @@ -0,0 +1,65 @@ +"""Resources primitive (lowlevel API): hand-built list/templates/read handlers.""" + +from typing import Any + +import mcp_types as types +from mcp_types.jsonrpc import INVALID_PARAMS + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from mcp.shared.exceptions import MCPError +from stories._hosting import run_server_from_args + + +def build_server() -> Server[Any]: + async def list_resources( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListResourcesResult: + return types.ListResourcesResult( + resources=[ + types.Resource( + uri="config://app", + name="app_config", + description="Static application config.", + mime_type="application/json", + ) + ] + ) + + async def list_resource_templates( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListResourceTemplatesResult: + return types.ListResourceTemplatesResult( + resource_templates=[ + types.ResourceTemplate( + uri_template="greeting://{name}", + name="greeting", + description="A greeting for the named subject.", + mime_type="text/plain", + ) + ] + ) + + async def read_resource( + ctx: ServerRequestContext[Any], params: types.ReadResourceRequestParams + ) -> types.ReadResourceResult: + if params.uri == "config://app": + text, mime = '{"feature": true}', "application/json" + elif params.uri.startswith("greeting://"): + text, mime = f"Hello, {params.uri.removeprefix('greeting://')}!", "text/plain" + else: + raise MCPError(code=INVALID_PARAMS, message=f"Resource not found: {params.uri}") + return types.ReadResourceResult( + contents=[types.TextResourceContents(uri=params.uri, mime_type=mime, text=text)] + ) + + return Server( + "resources-example", + on_list_resources=list_resources, + on_list_resource_templates=list_resource_templates, + on_read_resource=read_resource, + ) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/roots/README.md b/examples/stories/roots/README.md new file mode 100644 index 0000000..d11bf88 --- /dev/null +++ b/examples/stories/roots/README.md @@ -0,0 +1,58 @@ +# roots + +> **Deprecated** in the 2026-07-28 protocol (SEP-2577); functional through the +> deprecation window. Migration: accept directory paths as ordinary tool +> parameters or resource URIs instead of relying on `roots/list`. +> TODO(maxisbey): revisit before beta. + +The client passes a `list_roots_callback` returning the filesystem locations it +is willing to expose; a server tool calls `ctx.session.list_roots()` mid-request +and the client's callback answers it. Passing the callback is what makes the +client advertise the `roots` capability — there is no separate flag. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.roots.client + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.roots.client --http --legacy +# same, against the lowlevel-API server variant +uv run python -m stories.roots.client --http --legacy --server server_lowlevel +``` + +## What to look at + +- `client.py` `main` — the + `Client(target, mode=mode, list_roots_callback=list_roots)` construction is + the whole client-side story: the callback is wired in as a constructor + argument, and that alone advertises the capability. +- `client.py` `list_roots` — the callback takes a `ClientRequestContext` and + returns `ListRootsResult`. +- `server.py` — `await ctx.session.list_roots()` inside the tool body: a + server→client request that blocks until the callback answers. +- `server_lowlevel.py` — the same call from `ServerRequestContext.session`, + with the `CallToolResult` built by hand. + +## Caveats + +- **Legacy-era only.** `roots/list` is a server-initiated request with no + 2026-07-28 wire carrier, so this story runs with `era = "legacy"` and the + harness pins the handshake path. +- `ctx.session.list_roots()` is `@deprecated`; the + `# pyright: ignore[reportDeprecated]` is deliberate. The non-deprecated + replacement is to accept directory paths as ordinary tool parameters (see the + banner above) — there is no successor server→client call. +- `ctx.session.*` is the interim 2-hop path; a later release will shorten it. +- `notifications/roots/list_changed` is intentionally not shown — removed in + 2026-07-28 (SEP-2575) and deprecated on the legacy path. + +## Spec + +[Roots — client features](https://modelcontextprotocol.io/specification/2025-11-25/client/roots) + +## See also + +`legacy_elicitation/`, `sampling/` — sibling stories that exercise the same +legacy server→client request shape. diff --git a/examples/stories/roots/__init__.py b/examples/stories/roots/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/roots/client.py b/examples/stories/roots/client.py new file mode 100644 index 0000000..9d82529 --- /dev/null +++ b/examples/stories/roots/client.py @@ -0,0 +1,31 @@ +"""Expose two filesystem roots and verify the server's tool can read them back.""" + +from mcp_types import ListRootsResult, Root, TextContent +from pydantic import FileUrl + +from mcp.client import Client, ClientRequestContext +from stories._harness import Target, run_client + + +async def list_roots(context: ClientRequestContext) -> ListRootsResult: + return ListRootsResult( + roots=[ + Root(uri=FileUrl("file:///workspace/project"), name="project"), + Root(uri=FileUrl("file:///workspace/scratch")), + ] + ) + + +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode, list_roots_callback=list_roots) as client: + result = await client.call_tool("show_roots", {}) + + assert not result.is_error, result + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == ("file:///workspace/project (project)\nfile:///workspace/scratch (unnamed)"), ( + result.content[0].text + ) + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/roots/server.py b/examples/stories/roots/server.py new file mode 100644 index 0000000..79e95f1 --- /dev/null +++ b/examples/stories/roots/server.py @@ -0,0 +1,19 @@ +"""Roots primitive: a tool asks the client which filesystem roots it may use.""" + +from mcp.server.mcpserver import Context, MCPServer +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer("roots-example") + + @mcp.tool(description="Return the filesystem roots the client has exposed.") + async def show_roots(ctx: Context) -> str: + result = await ctx.session.list_roots() # pyright: ignore[reportDeprecated] + return "\n".join(f"{root.uri} ({root.name or 'unnamed'})" for root in result.roots) + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/roots/server_lowlevel.py b/examples/stories/roots/server_lowlevel.py new file mode 100644 index 0000000..2696c94 --- /dev/null +++ b/examples/stories/roots/server_lowlevel.py @@ -0,0 +1,36 @@ +"""Roots primitive (lowlevel API): the same server→client round-trip, hand-built.""" + +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + + +def build_server() -> Server[Any]: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="show_roots", + description="Return the filesystem roots the client has exposed.", + input_schema={"type": "object"}, + ), + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "show_roots" + result = await ctx.session.list_roots() # pyright: ignore[reportDeprecated] + lines = [f"{root.uri} ({root.name or 'unnamed'})" for root in result.roots] + return types.CallToolResult(content=[types.TextContent(text="\n".join(lines))]) + + return Server("roots-example", on_list_tools=list_tools, on_call_tool=call_tool) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/sampling/README.md b/examples/stories/sampling/README.md new file mode 100644 index 0000000..1c4a9bf --- /dev/null +++ b/examples/stories/sampling/README.md @@ -0,0 +1,62 @@ +# sampling + +> **Deprecated** in the 2026-07-28 protocol (SEP-2577); functional through the +> deprecation window. Migration: call your LLM provider directly from the +> server instead of requesting completions through the client. +> TODO(maxisbey): revisit before beta. + +A tool that asks the **client's** LLM for a completion mid-call — the inverted +MCP direction. The server holds no model API key; it awaits +`ctx.session.create_message(...)` and the client's `sampling_callback` answers. +Registering the callback is what makes the client advertise the `sampling` +capability — there is no separate flag. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.sampling.client + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.sampling.client --http --legacy +# same, against the lowlevel-API server variant +uv run python -m stories.sampling.client --http --legacy --server server_lowlevel +``` + +## What to look at + +- `client.py` `main` — `async with Client(target, mode=mode, + sampling_callback=on_sample) as client:`. The callback is an ordinary + constructor kwarg; registering it is the whole opt-in. +- `client.py` `on_sample` — takes `(ClientRequestContext, + CreateMessageRequestParams)` and returns a `CreateMessageResult`. A real + host calls its LLM provider here; the example returns a canned answer so the + round-trip is assertable. +- `server.py` — `await ctx.session.create_message(...)` inside the tool body: a + server→client request that blocks until the callback answers. There is no + `Context.sample()` sugar; reaching `ctx.session` is the public path. +- `server_lowlevel.py` — the same call from `ServerRequestContext.session`, + with the `CallToolResult` built by hand. + +## Caveats + +- **Legacy-era only.** `sampling/createMessage` is a server-initiated request + with no 2026-07-28 wire carrier, so this story runs with `era = "legacy"` and + the harness pins the handshake path. +- `ctx.session.create_message()` is `@deprecated`; the + `# pyright: ignore[reportDeprecated]` is deliberate. The non-deprecated + replacement is to call your LLM provider directly from the server (see the + banner above) — there is no successor server→client call. +- `ctx.session.*` is the interim 2-hop path; a later release will shorten it. +- `Client` has no `sampling_capabilities=` kwarg, so the `sampling.tools` + sub-capability (tools-in-sampling) is unreachable from the high-level client. + Drop to `ClientSession` if you need it. + +## Spec + +[Sampling — client features](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) + +## See also + +`legacy_elicitation/`, `roots/` — sibling stories that exercise the same legacy +server→client request shape. diff --git a/examples/stories/sampling/__init__.py b/examples/stories/sampling/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/sampling/client.py b/examples/stories/sampling/client.py new file mode 100644 index 0000000..0ca88db --- /dev/null +++ b/examples/stories/sampling/client.py @@ -0,0 +1,30 @@ +"""Supply a canned sampling_callback and assert its text round-trips through the tool.""" + +from mcp_types import CreateMessageRequestParams, CreateMessageResult, TextContent + +from mcp.client import Client, ClientRequestContext +from stories._harness import Target, run_client + + +async def on_sample(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult: + # A real host would call its LLM provider here; the example returns a deterministic + # canned answer so the round-trip is assertable. + return CreateMessageResult( + role="assistant", + content=TextContent(text="[canned summary]"), + model="stub-model", + stop_reason="endTurn", + ) + + +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode, sampling_callback=on_sample) as client: + result = await client.call_tool("summarize", {"text": "hello world"}) + + assert not result.is_error, result + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "[canned summary]", result.content[0].text + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/sampling/server.py b/examples/stories/sampling/server.py new file mode 100644 index 0000000..c97d8ab --- /dev/null +++ b/examples/stories/sampling/server.py @@ -0,0 +1,25 @@ +"""Sampling primitive: a tool asks the client's LLM for a completion mid-call.""" + +from mcp_types import SamplingMessage, TextContent + +from mcp.server.mcpserver import Context, MCPServer +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer("sampling-example") + + @mcp.tool(description="Summarize text by asking the host's LLM via sampling/createMessage.") + async def summarize(text: str, ctx: Context) -> str: + result = await ctx.session.create_message( # pyright: ignore[reportDeprecated] + messages=[SamplingMessage(role="user", content=TextContent(text=f"Summarize in one sentence:\n\n{text}"))], + max_tokens=200, + ) + assert isinstance(result.content, TextContent) + return result.content.text + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/sampling/server_lowlevel.py b/examples/stories/sampling/server_lowlevel.py new file mode 100644 index 0000000..5bc2a19 --- /dev/null +++ b/examples/stories/sampling/server_lowlevel.py @@ -0,0 +1,45 @@ +"""Sampling primitive (lowlevel API): the same server→client round-trip, hand-built.""" + +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + + +def build_server() -> Server[Any]: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="summarize", + description="Summarize text by asking the host's LLM via sampling/createMessage.", + input_schema={ + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], + }, + ), + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "summarize" + assert params.arguments is not None + prompt = f"Summarize in one sentence:\n\n{params.arguments['text']}" + result = await ctx.session.create_message( # pyright: ignore[reportDeprecated] + messages=[types.SamplingMessage(role="user", content=types.TextContent(text=prompt))], + max_tokens=200, + ) + assert isinstance(result.content, types.TextContent) + return types.CallToolResult(content=[types.TextContent(text=result.content.text)]) + + return Server("sampling-example", on_list_tools=list_tools, on_call_tool=call_tool) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/schema_validators/README.md b/examples/stories/schema_validators/README.md new file mode 100644 index 0000000..984f159 --- /dev/null +++ b/examples/stories/schema_validators/README.md @@ -0,0 +1,52 @@ +# schema-validators + +Four ways to type a tool parameter so `MCPServer` derives the JSON-Schema +`inputSchema` and validates arguments before your handler runs: a pydantic +`BaseModel`, a `TypedDict`, a `@dataclass`, and a bare `dict[str, Any]`. The +client lists the tools, resolves each `who` schema, and round-trips a call. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.schema_validators.client + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.schema_validators.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.schema_validators.client --http --server server_lowlevel +``` + +## What to look at + +- `client.py` `main` — the body opens with `async with Client(target, mode=mode) + as client:`. `target` is anything `Client` accepts (an in-process server, a + transport, or an HTTP URL); the entry point picks it, the story constructs it. +- `server.py` — `who.name` vs `who["name"]`: pydantic and dataclass parameters + arrive as **instances** (attribute access); TypedDict and `dict[str, Any]` + arrive as plain dicts. +- `client.py` — the listed `inputSchema` for the three typed variants nests a + `$defs`/`$ref` object with a `name` property; `greet_dict` publishes only + `{"type": "object", "additionalProperties": true}` — no field validation. +- `server_lowlevel.py` — the same schemas written by hand. There is no + reflection layer at this tier; you author JSON Schema and unpack + `params.arguments` yourself. + +## Caveats + +- Pydantic emits local `#/$defs/` references for nested models. The SDK does + not dereference network `$ref`s (SEP-2106 MUST NOT); only same-document refs + are resolved during validation. +- `PersonTD` is `total=True`, so its nested schema requires both `name` and + `title`; the `BaseModel` and `@dataclass` variants default `title="friend"`, + so only `name` is required there. Use `typing.NotRequired[...]` to mark + optional TypedDict fields. + +## Spec + +[Tools — input schema](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#input-schema) + +## See also + +`tools/` (output schema → `structuredContent`), `error_handling/` (what +happens when validation fails). diff --git a/examples/stories/schema_validators/__init__.py b/examples/stories/schema_validators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/schema_validators/client.py b/examples/stories/schema_validators/client.py new file mode 100644 index 0000000..8f6794e --- /dev/null +++ b/examples/stories/schema_validators/client.py @@ -0,0 +1,38 @@ +"""Asserts each variant publishes a `who` object schema and the call round-trips.""" + +from mcp_types import TextContent + +from mcp.client import Client +from stories._harness import Target, run_client + + +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + listed = await client.list_tools() + by_name = {t.name: t for t in listed.tools} + assert set(by_name) == {"greet_pydantic", "greet_typeddict", "greet_dataclass", "greet_dict"} + + for name in ("greet_pydantic", "greet_typeddict", "greet_dataclass"): + schema = by_name[name].input_schema + assert schema["required"] == ["who"], schema + # MCPServer emits a $defs/$ref pair; lowlevel inlines. Resolve either. + who = schema["properties"]["who"] + if "$ref" in who: + who = schema["$defs"][who["$ref"].rsplit("/", 1)[-1]] + assert "name" in who["properties"], who + + result = await client.call_tool(name, {"who": {"name": "Ada", "title": "colleague"}}) + assert not result.is_error, result + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Hello Ada, my colleague" + + # dict[str, Any] → free-form object schema, no nested `properties` required. + dict_who = by_name["greet_dict"].input_schema["properties"]["who"] + assert dict_who["type"] == "object" and "$ref" not in dict_who + result = await client.call_tool("greet_dict", {"who": {"name": "Ada"}}) + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Hello Ada, my friend" + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/schema_validators/server.py b/examples/stories/schema_validators/server.py new file mode 100644 index 0000000..8648e21 --- /dev/null +++ b/examples/stories/schema_validators/server.py @@ -0,0 +1,59 @@ +"""Four ways to type a tool parameter so MCPServer derives and enforces inputSchema.""" + +from dataclasses import dataclass +from typing import Any + +from pydantic import BaseModel + +# pydantic requires typing_extensions.TypedDict (not typing.TypedDict) on Python < 3.12 +# when a TypedDict is used as a field/parameter type. +from typing_extensions import TypedDict + +from mcp.server.mcpserver import MCPServer +from stories._hosting import run_server_from_args + + +class PersonModel(BaseModel): + name: str + title: str = "friend" + + +class PersonTD(TypedDict): + name: str + title: str + + +@dataclass +class PersonDC: + name: str + title: str = "friend" + + +def build_server() -> MCPServer: + mcp = MCPServer("schema-validators-example") + + @mcp.tool() + def greet_pydantic(who: PersonModel) -> str: + """`who` arrives as a validated PersonModel instance.""" + return f"Hello {who.name}, my {who.title}" + + @mcp.tool() + def greet_typeddict(who: PersonTD) -> str: + """`who` arrives as a plain dict; TypedDict drives the schema and editor hints.""" + return f"Hello {who['name']}, my {who['title']}" + + @mcp.tool() + def greet_dataclass(who: PersonDC) -> str: + """`who` arrives as a PersonDC instance (pydantic coerces the wire dict).""" + return f"Hello {who.name}, my {who.title}" + + @mcp.tool() + def greet_dict(who: dict[str, Any]) -> str: + """`who` is a free-form object — any dict passes; the handler must check it.""" + return f"Hello {who['name']}, my {who.get('title', 'friend')}" + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/schema_validators/server_lowlevel.py b/examples/stories/schema_validators/server_lowlevel.py new file mode 100644 index 0000000..02dca8d --- /dev/null +++ b/examples/stories/schema_validators/server_lowlevel.py @@ -0,0 +1,55 @@ +"""Same four tools via lowlevel.Server — inputSchema is hand-written JSON Schema.""" + +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + +# With lowlevel.Server there is no reflection layer: you author the JSON Schema +# yourself and validate/unpack `params.arguments` in the handler. +PERSON_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"name": {"type": "string"}, "title": {"type": "string"}}, + "required": ["name"], +} +TOOLS = [ + types.Tool( + name=f"greet_{variant}", + description=f"Greet ({variant} input shape)", + input_schema={"type": "object", "properties": {"who": PERSON_SCHEMA}, "required": ["who"]}, + ) + for variant in ("pydantic", "typeddict", "dataclass") +] +TOOLS.append( + types.Tool( + name="greet_dict", + description="Greet (free-form dict input)", + input_schema={ + "type": "object", + "properties": {"who": {"type": "object", "additionalProperties": True}}, + "required": ["who"], + }, + ) +) + + +def build_server() -> Server[Any]: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=TOOLS) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.arguments is not None + who = params.arguments["who"] + text = f"Hello {who['name']}, my {who.get('title', 'friend')}" + return types.CallToolResult(content=[types.TextContent(text=text)]) + + return Server("schema-validators-example", on_list_tools=list_tools, on_call_tool=call_tool) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/serve_one/README.md b/examples/stories/serve_one/README.md new file mode 100644 index 0000000..dd75486 --- /dev/null +++ b/examples/stories/serve_one/README.md @@ -0,0 +1,60 @@ +# serve-one + +The kernel layer beneath `MCPServer.run()` / `run_server_from_args`. Every +transport entry composes the same three pieces: a `lowlevel.Server` (the +handler registry), a `Connection` (per-peer state), and a driver — `serve_one` +for one request → result dict, or `serve_connection` for a dispatcher loop. +This is what you write to bring up MCP over a custom transport. Uniquely, the +server file here builds the stdio entry by hand instead of importing +`stories._hosting`. + +## Run it + +```bash +# stdio (default — the client spawns server.py as a subprocess; its __main__ +# is the hand-built serve_connection loop) +uv run python -m stories.serve_one.client +``` + +## What to look at + +- `server.py::handle_one` — `Connection.from_envelope(...)` + `serve_one(...)` + returns the raw result dict for one request. No handshake, no streams; the + entry owns wire encoding and exception→error mapping. +- `server.py::main` — `JSONRPCDispatcher` + `Connection.for_loop(...)` + + `serve_connection(...)`: exactly what `Server.run()` does internally for + stdio. +- `server.py::SingleExchangeContext` — the per-request `DispatchContext` a + custom entry must supply. The SDK ships no public concrete class for this + yet. +- `client.py` — drives `handle_one` directly and asserts the raw result-dict + shape (`structuredContent` / `content`), then proves the loop-mode driver + works over the wire. + +## Caveats + +- **Deep imports** — `serve_one`, `serve_connection`, and `Connection` are only + reachable at `mcp.server.runner` / `mcp.server.connection` today; a shorter + `mcp.server.*` re-export is tracked for beta. +- **Lowlevel-only.** The drivers take a `lowlevel.Server` and `MCPServer` has + no public accessor for its underlying one (`_lowlevel_server` is private), so + there is no `MCPServer`-tier variant of this story. Build the lowlevel + `Server` directly until that accessor lands. +- **No public `DispatchContext`** — `SingleExchangeContext` is hand-rolled + boilerplate; a public helper (or a `serve_one` overload that builds one) is + tracked for beta. +- **Lifespan** — the transport entry enters `server.lifespan(server)` **once** + and threads `lifespan_state` to every `handle_one()` call; never enter it + per-request. +- `ServerRunner` is kernel-internal; never construct it directly. The + free-function drivers are the supported surface. + +## Spec + +[Architecture — lifecycle](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle) +· [2026 versioning — discover](https://modelcontextprotocol.io/specification/draft/server/discover) + +## See also + +`legacy_routing/` (composing `serve_one` behind `classify_inbound_request`), +`dual_era/` (`Connection.protocol_version` in handlers). diff --git a/examples/stories/serve_one/__init__.py b/examples/stories/serve_one/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/serve_one/client.py b/examples/stories/serve_one/client.py new file mode 100644 index 0000000..73bd457 --- /dev/null +++ b/examples/stories/serve_one/client.py @@ -0,0 +1,39 @@ +"""Drive `handle_one` directly to assert the raw result-dict shape, then over the wire.""" + +import mcp_types as types +from mcp_types.version import LATEST_MODERN_VERSION + +from mcp.client import Client +from stories._harness import Target, run_client +from stories.serve_one.server import build_server, handle_one + + +async def main(target: Target, *, mode: str = "auto") -> None: + # ── direct: the namesake recipe — Connection.from_envelope + serve_one → raw result dict. + # The entry enters lifespan once and threads it to every per-request handle_one(). + server = build_server() + params = { + "name": "add", + "arguments": {"a": 2, "b": 3}, + "_meta": { + types.PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION, + types.CLIENT_INFO_META_KEY: {"name": "serve-one-probe", "version": "0.0.0"}, + types.CLIENT_CAPABILITIES_META_KEY: {}, + }, + } + async with server.lifespan(server) as lifespan_state: + raw = await handle_one(server, "tools/call", params, lifespan_state=lifespan_state) + assert raw["structuredContent"] == {"result": 5}, raw + assert raw["content"][0] == {"type": "text", "text": "5"}, raw + + # ── over the wire: the loop-mode driver behind the connected client. + async with Client(target, mode=mode) as client: + listed = await client.list_tools() + assert [t.name for t in listed.tools] == ["add"] + + result = await client.call_tool("add", {"a": 2, "b": 3}) + assert result.structured_content == {"result": 5}, result + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/serve_one/server.py b/examples/stories/serve_one/server.py new file mode 100644 index 0000000..447e4a8 --- /dev/null +++ b/examples/stories/serve_one/server.py @@ -0,0 +1,110 @@ +"""serve_one / serve_connection mechanics: the kernel drivers a transport entry composes. + +`handle_one()` is the modern single-exchange recipe (`Connection.from_envelope` ++ `serve_one` → raw result dict). `main()` is the loop recipe +(`JSONRPCDispatcher` + `Connection.for_loop` + `serve_connection`) — what +`Server.run()` does for stdio. Both drivers take a `lowlevel.Server`, so this is +a lowlevel-only story: `MCPServer` has no public accessor for its underlying +`Server` yet. +""" + +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any + +import anyio +import mcp_types as types +from mcp_types.version import LATEST_MODERN_VERSION + +from mcp.server.connection import Connection # deep-path import; shorter re-export planned +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from mcp.server.runner import serve_connection, serve_one # deep-path import; shorter re-export planned +from mcp.server.stdio import stdio_server +from mcp.shared.exceptions import NoBackChannelError +from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher +from mcp.shared.transport_context import TransportContext + +__all__ = ["SingleExchangeContext", "build_server", "handle_one"] + + +def build_server() -> Server[Any]: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[types.Tool(name="add", description="Add two integers.", input_schema={"type": "object"})] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "add" and params.arguments is not None + total = params.arguments["a"] + params.arguments["b"] + return types.CallToolResult(content=[types.TextContent(text=str(total))], structured_content={"result": total}) + + return Server("serve-one-example", on_list_tools=list_tools, on_call_tool=call_tool) + + +@dataclass +class SingleExchangeContext: + """Minimal `DispatchContext` for one inbound request with no back-channel. + + A custom transport entry hand-builds one of these per request. The SDK + ships no public concrete class for this yet; this is the structural minimum. + """ + + request_id: int | str | None + transport: TransportContext = field(default_factory=lambda: TransportContext(kind="custom", can_send_request=False)) + message_metadata: None = None + can_send_request: bool = False + cancel_requested: anyio.Event = field(default_factory=anyio.Event) + + async def send_raw_request(self, method: str, params: Mapping[str, Any] | None, opts: Any = None) -> dict[str, Any]: + raise NoBackChannelError(method) + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: Any = None) -> None: + return None + + async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: + return None + + +async def handle_one( + server: Server[Any], method: str, params: Mapping[str, Any], *, lifespan_state: Any +) -> dict[str, Any]: + """Serve exactly one modern-era request and return its raw result dict. + + Reads the envelope from `params._meta` (the 2026 wire shape), builds a + born-ready `Connection.from_envelope`, and drives `serve_one`. The transport + entry enters `server.lifespan(server)` once and threads `lifespan_state` to + every call — never enter the lifespan per-request. + """ + meta = params.get("_meta", {}) + connection = Connection.from_envelope( + meta.get(types.PROTOCOL_VERSION_META_KEY, LATEST_MODERN_VERSION), + meta.get(types.CLIENT_INFO_META_KEY), + meta.get(types.CLIENT_CAPABILITIES_META_KEY), + ) + return await serve_one( + server, + SingleExchangeContext(request_id=1), + method, + params, + connection=connection, + lifespan_state=lifespan_state, + ) + + +async def main() -> None: + """Serve over stdio by building the dispatcher + Connection by hand (loop mode).""" + server = build_server() + async with server.lifespan(server) as lifespan_state: + async with stdio_server() as (read_stream, write_stream): + dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher( + read_stream, write_stream, inline_methods=frozenset({"initialize"}) + ) + connection = Connection.for_loop(dispatcher) + await serve_connection(server, dispatcher, connection=connection, lifespan_state=lifespan_state) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/examples/stories/skills/README.md b/examples/stories/skills/README.md new file mode 100644 index 0000000..d984fe5 --- /dev/null +++ b/examples/stories/skills/README.md @@ -0,0 +1,14 @@ +# skills + +SEP-2640 skills: a server exposes a `skill://index.json` directory resource and +`@skill` / `@skillDir` registrations that a host can read to bootstrap +agent-level instructions. The story will list skills and read one. + +**Status: not yet implemented** ([#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896)). +The `extensions` capability map is not yet surfaced on `MCPServer`, so a server +cannot advertise the skills extension. + +## Spec + +[SEP-2640 — skills](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2640) +· [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133) diff --git a/examples/stories/sse_polling/README.md b/examples/stories/sse_polling/README.md new file mode 100644 index 0000000..ddd1b61 --- /dev/null +++ b/examples/stories/sse_polling/README.md @@ -0,0 +1,76 @@ +# sse-polling + +> **Legacy mechanism (2025 handshake era).** `Last-Event-ID` resumability and +> the sessionful transport are removed in the 2026-07-28 protocol (SEP-2575) +> with no modern-era equivalent; the closest 2026-era pattern is client-side +> reconnection over a persisted `DiscoverResult` — +> [`reconnect/`](../reconnect/). TODO(maxisbey): revisit before beta. + +SEP-1699 server-initiated SSE disconnection with `Last-Event-ID` replay. The +server's `EventStore` stamps every SSE event with an ID and opens each response +stream with a priming event; mid-handler the tool calls +`ctx.close_sse_stream()` to release the open HTTP response (freeing a +connection slot), keeps emitting progress into the event store, and returns. +The client transport sees the stream end, reconnects with `Last-Event-ID`, and +the event store replays everything it missed — `await client.call_tool(...)` +resolves as if the disconnect never happened. + +## Run it + +```bash +# HTTP — the client self-hosts the app on a free port, runs, then tears it down +uv run python -m stories.sse_polling.client --http --legacy +# same, against the lowlevel-API server variant +uv run python -m stories.sse_polling.client --http --legacy --server server_lowlevel + +# against a server you run yourself (real uvicorn on :8000) +uv run python -m stories.sse_polling.server --port 8000 & +SERVER_PID=$! +uv run python -m stories.sse_polling.client --http http://127.0.0.1:8000/mcp --legacy +kill "$SERVER_PID" +``` + +## What to look at + +- **`client.py` `main` — opens with `async with Client(target, mode=mode)`.** + There is no client-side resumability configuration: the `Client` and the + `streamable_http_client` transport handle the priming event, the SSE `retry:` + hint, and the `Last-Event-ID` reconnect automatically. The assertion that the + `"after-close"` progress message arrived is the proof — it was emitted while + no SSE stream was open. +- **`server.py` — `streamable_http_app(event_store=..., retry_interval=0)`.** + Passing an `EventStore` is what enables resumability: every SSE event gets an + ID and the response opens with a priming event so the client always has a + `Last-Event-ID` to reconnect with. `retry_interval=0` makes the client's + reconnect wait a no-op (the SSE `retry:` hint). +- **`server.py` — `await ctx.close_sse_stream()`.** Ends the current request's + SSE response without cancelling the handler. Everything emitted afterwards + goes to the event store and is replayed on reconnect. A no-op when no + `event_store` is configured. +- **`server_lowlevel.py` — `ctx.close_sse_stream`.** On the lowlevel API the + callback is an optional field on `ServerRequestContext`; it is `None` unless + an event store is wired and the negotiated version is in the 2025 era. + +## Caveats + +- `streamable_http_app(...)` is a hosting entry that reshapes in a later + release; this story calls it directly because the event-store and + retry-interval kwargs are the point. +- DNS-rebinding protection is disabled (`transport_security=NO_DNS_REBIND`) + because the in-process httpx client sends no `Origin` header. Drop the kwarg + for a real deployment. +- `event_store.py` here is example-grade only (sequential IDs, no eviction). A + production server would back the `EventStore` interface with persistent + storage. + +## Spec + +[Resumability and Redelivery](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery) +· SEP-1699 (server-initiated SSE close) + +## See also + +`standalone_get/` (the standalone-stream sibling of `close_sse_stream()`), +`reconnect/` (the modern-era reconnection story — persisted `DiscoverResult`, +no event store), `streaming/` (in-flight progress + cancellation without the +disconnect). diff --git a/examples/stories/sse_polling/__init__.py b/examples/stories/sse_polling/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/sse_polling/client.py b/examples/stories/sse_polling/client.py new file mode 100644 index 0000000..d2f3918 --- /dev/null +++ b/examples/stories/sse_polling/client.py @@ -0,0 +1,32 @@ +"""Call a tool whose SSE stream the server closes mid-flight; the call still completes. HTTP-only — no SSE on stdio.""" + +import anyio +from mcp_types import TextContent + +from mcp.client import Client +from stories._harness import Target, run_client + + +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + messages: list[str | None] = [] + + async def on_progress(progress: float, total: float | None, message: str | None) -> None: + messages.append(message) + + with anyio.fail_after(10): + result = await client.call_tool("long_operation", {}, progress_callback=on_progress) + + # The result arrived — the client transport survived the server-initiated close, + # reconnected with Last-Event-ID, and received the replayed response. + assert not result.is_error, result + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "resumed" + + # "after-close" was emitted while no SSE stream was open; receiving it proves the + # event store buffered it and the reconnect replayed it. + assert messages == ["before-close", "after-close"], messages + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/sse_polling/event_store.py b/examples/stories/sse_polling/event_store.py new file mode 100644 index 0000000..95d2b8a --- /dev/null +++ b/examples/stories/sse_polling/event_store.py @@ -0,0 +1,34 @@ +"""Minimal in-memory `EventStore` for the SSE-resumability example. + +Sequential integer IDs so the wire is readable; a production server would back +this interface with persistent storage so replay survives a process restart. +""" + +from mcp_types import JSONRPCMessage + +from mcp.server.streamable_http import EventCallback, EventId, EventMessage, EventStore, StreamId + + +class InMemoryEventStore(EventStore): + """Stores every event in arrival order and replays the same-stream tail after a given ID.""" + + def __init__(self) -> None: + self._events: list[tuple[StreamId, JSONRPCMessage | None]] = [] + + async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId: + self._events.append((stream_id, message)) + return str(len(self._events)) + + async def replay_events_after(self, last_event_id: EventId, send_callback: EventCallback) -> StreamId | None: + try: + cursor = int(last_event_id) + except ValueError: + return None + if not 0 < cursor <= len(self._events): + return None + stream_id, _ = self._events[cursor - 1] + for index in range(cursor, len(self._events)): + event_stream_id, message = self._events[index] + if event_stream_id == stream_id and message is not None: + await send_callback(EventMessage(message, str(index + 1))) + return stream_id diff --git a/examples/stories/sse_polling/server.py b/examples/stories/sse_polling/server.py new file mode 100644 index 0000000..1098ca6 --- /dev/null +++ b/examples/stories/sse_polling/server.py @@ -0,0 +1,35 @@ +"""SEP-1699: a tool closes its own SSE stream mid-call; the event store buffers the rest. Exports `build_app()`.""" + +from starlette.applications import Starlette + +from mcp.server.mcpserver import Context, MCPServer +from stories._hosting import NO_DNS_REBIND, run_app_from_args +from stories.sse_polling.event_store import InMemoryEventStore + + +def build_app() -> Starlette: + mcp = MCPServer("sse-polling-example") + + @mcp.tool() + async def long_operation(ctx: Context) -> str: + """Emit progress, close this call's SSE stream, emit more progress, then return. + + Everything sent after `close_sse_stream()` lands in the event store and is + replayed when the client reconnects with `Last-Event-ID`. + """ + await ctx.report_progress(0.5, total=1.0, message="before-close") + await ctx.close_sse_stream() + await ctx.report_progress(1.0, total=1.0, message="after-close") + return "resumed" + + # event_store enables Last-Event-ID replay; retry_interval=0 makes the client's + # reconnect wait a no-op so the example is deterministic without real time. + return mcp.streamable_http_app( + event_store=InMemoryEventStore(), + retry_interval=0, + transport_security=NO_DNS_REBIND, + ) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/sse_polling/server_lowlevel.py b/examples/stories/sse_polling/server_lowlevel.py new file mode 100644 index 0000000..fcf3199 --- /dev/null +++ b/examples/stories/sse_polling/server_lowlevel.py @@ -0,0 +1,45 @@ +"""SEP-1699 polling on the lowlevel `Server`: close the request's SSE stream mid-handler.""" + +from typing import Any + +import mcp_types as types +from starlette.applications import Starlette + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import NO_DNS_REBIND, run_app_from_args +from stories.sse_polling.event_store import InMemoryEventStore + +_TOOL = types.Tool( + name="long_operation", + description="Emit progress, close the SSE stream, emit more, return.", + input_schema={"type": "object", "properties": {}}, +) + + +def build_app() -> Starlette: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[_TOOL]) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "long_operation" + await ctx.session.report_progress(0.5, total=1.0, message="before-close") + # The transport only wires this callback when an event_store is configured and the + # negotiated version is in the 2025 era; it is None otherwise. + if ctx.close_sse_stream is not None: + await ctx.close_sse_stream() + await ctx.session.report_progress(1.0, total=1.0, message="after-close") + return types.CallToolResult(content=[types.TextContent(text="resumed")]) + + server = Server("sse-polling-example", on_list_tools=list_tools, on_call_tool=call_tool) + return server.streamable_http_app( + event_store=InMemoryEventStore(), + retry_interval=0, + transport_security=NO_DNS_REBIND, + ) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/standalone_get/README.md b/examples/stories/standalone_get/README.md new file mode 100644 index 0000000..c460e14 --- /dev/null +++ b/examples/stories/standalone_get/README.md @@ -0,0 +1,67 @@ +# standalone-get + +> **Legacy mechanism (2025 handshake era).** The 2026-07-28 protocol delivers +> server-initiated notifications over a `subscriptions/listen` stream instead +> of the standalone GET stream. TODO(maxisbey): unify once +> `subscriptions/listen` lands +> ([#2901](https://github.com/modelcontextprotocol/python-sdk/issues/2901)). + +Server-initiated `notifications/resources/list_changed` delivered over the +**standalone GET SSE stream** of a sessionful Streamable-HTTP connection. The +`add_note` tool mutates the resource list and emits the notification with no +related request; the client's `message_handler` receives it on the GET stream, +awaits it on an `anyio.Event`, then re-lists to observe the change. + +## Run it + +```bash +# HTTP only — the standalone GET stream is a Streamable-HTTP feature. The +# client self-hosts the server on a free port, runs, then tears it down. +uv run python -m stories.standalone_get.client --http --legacy +# same, against the lowlevel-API server variant +uv run python -m stories.standalone_get.client --http --legacy --server server_lowlevel + +# against a server you run yourself +uv run python -m stories.standalone_get.server --http --port 8000 & +SERVER_PID=$! +uv run python -m stories.standalone_get.client --http http://127.0.0.1:8000/mcp --legacy +kill "$SERVER_PID" +``` + +## What to look at + +- **`client.py` — `Client(target, mode=mode, message_handler=on_message)`.** + Unsolicited notifications have no typed callback, so the catch-all + `message_handler` is wired at construction — it (and the `anyio.Event` it + sets) must exist *before* the connection does. The notification is not + guaranteed to arrive before the tool result (different streams), so the body + `await`s the event, bounded by `anyio.fail_after(5)`. +- **`server.py` — `await ctx.session.send_resource_list_changed()`.** + `MCPServer.add_resource` does **not** auto-emit (unlike the TypeScript SDK's + `registerResource`); the explicit call is the teaching point. Because + `send_*_list_changed()` carries no `related_request_id`, the only route to the + client is the standalone GET stream. + +## Caveats + +- DNS-rebinding protection is disabled via `transport_security=NO_DNS_REBIND` + because the in-process httpx client sends no `Origin` header. Drop the kwarg + for a real deployment. +- Neither `MCPServer` nor lowlevel `Server` auto-advertises + `resources.listChanged: true` in capabilities, and `MCPServer` exposes no knob + to set it. A spec-conformant client that gates on the capability flag would + skip the handler. +- `ctx.session.*` is the interim path; a later release will shorten it. +- Tool-triggered, not timer-driven, for harness determinism. "Server pushes on + its own schedule" is not demonstrated. + +## Spec + +[List Changed Notification](https://modelcontextprotocol.io/specification/2025-11-25/server/resources#list-changed-notification), +[Streamable HTTP — Listening for Messages](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server) + +## See also + +`stickynotes/` (list_changed inside a feature capstone), `sse_polling/` (the +other GET-stream story — resumability), `json_response/` (what happens when the +server can't stream). diff --git a/examples/stories/standalone_get/__init__.py b/examples/stories/standalone_get/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/standalone_get/client.py b/examples/stories/standalone_get/client.py new file mode 100644 index 0000000..aaf870f --- /dev/null +++ b/examples/stories/standalone_get/client.py @@ -0,0 +1,40 @@ +"""Receive `notifications/resources/list_changed` over the standalone GET stream, then re-list.""" + +import anyio +import mcp_types as types + +from mcp.client import Client +from stories._harness import Target, run_client + + +async def main(target: Target, *, mode: str = "auto") -> None: + # `message_handler` is constructor-only on `Client`, so the event it sets + # has to exist before the connection does. + received: list[types.ResourceListChangedNotification] = [] + seen = anyio.Event() + + async def on_message(message: object) -> None: + if isinstance(message, types.ResourceListChangedNotification): + received.append(message) + seen.set() + + async with Client(target, mode=mode, message_handler=on_message) as client: + before = await client.list_resources() + assert len(before.resources) >= 1, before + + result = await client.call_tool("add_note", {"content": "hello"}) + assert not result.is_error, result + + # The notification rides the standalone GET stream, not the call's POST stream — + # delivery order vs the tool result is not guaranteed, so wait. + with anyio.fail_after(5): + await seen.wait() + assert len(received) == 1, received + + after = await client.list_resources() + assert len(after.resources) == len(before.resources) + 1, after + assert {r.name for r in after.resources} >= {"initial", "note-1"} + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/standalone_get/server.py b/examples/stories/standalone_get/server.py new file mode 100644 index 0000000..4b0c956 --- /dev/null +++ b/examples/stories/standalone_get/server.py @@ -0,0 +1,30 @@ +"""Sessionful Streamable HTTP: a tool mutates resources and emits `list_changed` over the standalone GET stream.""" + +import itertools + +from mcp.server.mcpserver import Context, MCPServer +from mcp.server.mcpserver.resources import TextResource +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer("standalone-get-example") + counter = itertools.count(1) + + mcp.add_resource(TextResource(uri="note://initial", name="initial", text="initial content")) + + @mcp.tool() + async def add_note(content: str, ctx: Context) -> str: + """Register a new resource and announce it via `notifications/resources/list_changed`.""" + name = f"note-{next(counter)}" + mcp.add_resource(TextResource(uri=f"note://{name}", name=name, text=content)) + # MCPServer does not auto-emit on add_resource; send explicitly. With no + # related_request_id this routes to the standalone GET stream. + await ctx.session.send_resource_list_changed() + return f"registered {name}" + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/standalone_get/server_lowlevel.py b/examples/stories/standalone_get/server_lowlevel.py new file mode 100644 index 0000000..21ee8c1 --- /dev/null +++ b/examples/stories/standalone_get/server_lowlevel.py @@ -0,0 +1,49 @@ +"""Sessionful Streamable HTTP (lowlevel `Server`): tool-triggered `list_changed` over the standalone GET stream.""" + +import itertools +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + +ADD_NOTE_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"content": {"type": "string"}}, + "required": ["content"], +} + + +def build_server() -> Server[Any]: + counter = itertools.count(1) + resources: list[types.Resource] = [types.Resource(uri="note://initial", name="initial", mime_type="text/plain")] + + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="add_note", input_schema=ADD_NOTE_INPUT_SCHEMA)]) + + async def list_resources( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListResourcesResult: + return types.ListResourcesResult(resources=list(resources)) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "add_note" and params.arguments is not None + name = f"note-{next(counter)}" + resources.append(types.Resource(uri=f"note://{name}", name=name, mime_type="text/plain")) + await ctx.session.send_resource_list_changed() + return types.CallToolResult(content=[types.TextContent(text=f"registered {name}")]) + + return Server( + "standalone-get-example", + on_list_tools=list_tools, + on_list_resources=list_resources, + on_call_tool=call_tool, + ) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/starlette_mount/README.md b/examples/stories/starlette_mount/README.md new file mode 100644 index 0000000..97b3a84 --- /dev/null +++ b/examples/stories/starlette_mount/README.md @@ -0,0 +1,58 @@ +# starlette-mount + +Embed an MCP server inside an existing Starlette (or FastAPI) app at a +sub-path, next to your own routes. `mcp.streamable_http_app()` returns a +mountable ASGI app; the two things to get right are the **path** (the default +`streamable_http_path="/mcp"` stacks under your mount prefix) and the +**lifespan** (Starlette does not run a mounted sub-app's lifespan, so the +parent must enter `mcp.session_manager.run()`). + +## Run it + +```bash +# HTTP — the client self-hosts the mounted app on a free port at /api/, runs, +# then tears it down +uv run python -m stories.starlette_mount.client --http + +# against a server you run yourself (real uvicorn on :8000) +uv run python -m stories.starlette_mount.server --port 8000 & +SERVER_PID=$! +curl http://127.0.0.1:8000/health # → {"status":"ok"} +uv run python -m stories.starlette_mount.client --http http://127.0.0.1:8000/api/ +kill "$SERVER_PID" +``` + +## What to look at + +- `client.py` `main` — opens with `async with Client(target, mode=mode) as + client:`. Nothing on the client side knows about the mount: the `/api/` URL + handed in as `target` is just another streamable-HTTP endpoint. +- `server.py` `streamable_http_path="/"` — without this the endpoint would be + `/api/mcp`; with it, `Mount("/api", ...)` serves MCP at `/api/` (trailing + slash required — Starlette's `Mount` forwards `/api` as an empty path that + the inner `/` route won't match). +- `server.py` `lifespan` — `mcp.session_manager.run()` **must** be entered by + the parent app. Forget it and every MCP request fails immediately with a 500 + (`RuntimeError: Task group is not initialized. Make sure to use run().`) — + the sub-app's own lifespan never fires under `Mount`. +- `server.py` `Route("/health", ...)` — non-MCP routes live alongside the + mount; FastAPI users do the same with `app.mount("/api", mcp_app)`. + +## Caveats + +- DNS-rebinding protection is on by default; the example passes + `transport_security=NO_DNS_REBIND` because the in-process test client sends + no `Origin` header. Remove it (or configure allowed hosts) for a real + deployment. +- The parent-lifespan dance is a known SDK ergonomics gap (other SDKs mount + with no extra ceremony); tracked for the beta reshape. The recipe shown here + is what works today. + +## Spec + +[Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) + +## See also + +`stateless_legacy/` (the one-liner `mcp.streamable_http_app()` without a parent +app), `json_response/`, `legacy_routing/`. TS-SDK equivalent: `examples/hono/`. diff --git a/examples/stories/starlette_mount/__init__.py b/examples/stories/starlette_mount/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/starlette_mount/client.py b/examples/stories/starlette_mount/client.py new file mode 100644 index 0000000..dcfc349 --- /dev/null +++ b/examples/stories/starlette_mount/client.py @@ -0,0 +1,23 @@ +"""Connect to the sub-mounted MCP endpoint at /api/, list tools and call greet. HTTP-only: the mount is the story.""" + +from mcp_types import TextContent + +from mcp.client import Client +from stories._harness import Target, run_client + + +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + listed = await client.list_tools() + assert [t.name for t in listed.tools] == ["greet"] + + result = await client.call_tool("greet", {"name": "Starlette"}) + assert not result.is_error + first = result.content[0] + assert isinstance(first, TextContent) + assert "Hello, Starlette!" in first.text, result + assert result.structured_content == {"result": "Hello, Starlette! (served from a Starlette sub-mount)"} + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/starlette_mount/server.py b/examples/stories/starlette_mount/server.py new file mode 100644 index 0000000..858abc9 --- /dev/null +++ b/examples/stories/starlette_mount/server.py @@ -0,0 +1,47 @@ +"""Mount an MCPServer in an existing Starlette app at a sub-path, alongside non-MCP routes; exports `build_app()`.""" + +import contextlib +from collections.abc import AsyncIterator + +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Mount, Route + +from mcp.server.mcpserver import MCPServer +from stories._hosting import NO_DNS_REBIND, run_app_from_args + + +def build_app() -> Starlette: + mcp = MCPServer("starlette-mount-example") + + @mcp.tool() + def greet(name: str) -> str: + """Return a greeting.""" + return f"Hello, {name}! (served from a Starlette sub-mount)" + + # streamable_http_path="/" so Mount("/api", ...) serves the MCP endpoint at + # /api itself, not /api/mcp. The returned sub-app has its own lifespan, but + # Starlette does not run nested lifespans under Mount — the parent app below + # must enter mcp.session_manager.run() itself. + mcp_app = mcp.streamable_http_app(streamable_http_path="/", transport_security=NO_DNS_REBIND) + + async def health(_request: Request) -> JSONResponse: + return JSONResponse({"status": "ok"}) + + @contextlib.asynccontextmanager + async def lifespan(_app: Starlette) -> AsyncIterator[None]: + async with mcp.session_manager.run(): + yield + + return Starlette( + routes=[ + Route("/health", health), + Mount("/api", app=mcp_app), + ], + lifespan=lifespan, + ) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/stateless_legacy/README.md b/examples/stories/stateless_legacy/README.md new file mode 100644 index 0000000..7fc1630 --- /dev/null +++ b/examples/stories/stateless_legacy/README.md @@ -0,0 +1,59 @@ +# stateless-legacy + +The one-liner HTTP deploy. `MCPServer.streamable_http_app(stateless_http=True)` +returns a complete ASGI app that serves **both** protocol eras on `/mcp`: 2025 +clients get the `initialize` handshake answered statelessly (no `Mcp-Session-Id`, +fresh transport per request, horizontally scalable), 2026 clients get the +per-request envelope path. Hand it straight to uvicorn — no session-manager +wiring, no era flag. The client connects once per era and asserts the same +`greet` tool answers identically either way. + +## Run it + +```bash +# HTTP — the client self-hosts the app on a free port, connects once as a +# modern client and once as a legacy client, then tears it down +uv run python -m stories.stateless_legacy.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.stateless_legacy.client --http --server server_lowlevel + +# against a server you run yourself (real uvicorn on :8000) +uv run python -m stories.stateless_legacy.server --port 8000 & +SERVER_PID=$! +uv run python -m stories.stateless_legacy.client --http http://127.0.0.1:8000/mcp +kill "$SERVER_PID" +``` + +## What to look at + +- `client.py` — two visible `Client(targets(), mode=...)` constructions against + the same URL. The first connects at the caller's `mode` (the real-user + `"auto"` default routes to the 2026 envelope path); the second pins + `mode="legacy"` and runs the `initialize` handshake. `client.protocol_version` + is the era-neutral accessor: two negotiated versions, identical tool result. +- `server.py` — `stateless_http=True` is the only knob; era routing is automatic + inside `StreamableHTTPSessionManager.handle_request`. The returned `Starlette` + already wires `lifespan=session_manager.run()`, so `uvicorn.run(app, ...)` + works with no parent-lifespan ceremony. +- `server_lowlevel.py` — `lowlevel.Server.streamable_http_app()` is the same + call; `MCPServer` delegates to it. + +## Caveats + +- `transport_security=NO_DNS_REBIND` — DNS-rebinding protection is on by default + for localhost binds; the harness disables it because the in-process httpx + client sends no `Origin` header. Drop the kwarg for a real deployment. +- `streamable_http_app()` reshapes in a later release; the call is isolated in + `build_app()` so the change touches one line per server file. + +## Spec + +[Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) +· [Versioning — backward compatibility](https://modelcontextprotocol.io/specification/draft/basic/versioning) + +## See also + +`dual_era/` (era branching inside a tool handler) · `legacy_routing/` +(`classify_inbound_request()` for sessionful-2025 + modern on one mount) · +`starlette_mount/` (mounting under FastAPI/Starlette with parent lifespan) · +`json_response/` (`json_response=True` and what it drops). diff --git a/examples/stories/stateless_legacy/__init__.py b/examples/stories/stateless_legacy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/stateless_legacy/client.py b/examples/stories/stateless_legacy/client.py new file mode 100644 index 0000000..d21ff85 --- /dev/null +++ b/examples/stories/stateless_legacy/client.py @@ -0,0 +1,37 @@ +"""Connect at each era — two connections, so `main` takes `targets`; the same stateless app answers both.""" + +from mcp_types import TextContent +from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION + +from mcp.client import Client +from stories._harness import TargetFactory, run_client + + +async def main(targets: TargetFactory, *, mode: str = "auto") -> None: + # ── modern era: the caller's mode (the real-user "auto" default) routes this connection + # through the 2026 envelope path. No initialize handshake, no session id. + async with Client(targets(), mode=mode) as client: + assert client.protocol_version == LATEST_MODERN_VERSION + + listed = await client.list_tools() + assert [t.name for t in listed.tools] == ["greet"] + + result = await client.call_tool("greet", {"name": "world"}) + assert not result.is_error + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Hello, world!", result + + # ── legacy era: a fresh mode="legacy" client runs the initialize handshake against the + # SAME stateless app. It is answered statelessly (no Mcp-Session-Id) and the same tool + # gives the same answer — the era is invisible to the server body. + async with Client(targets(), mode="legacy") as legacy: + assert legacy.protocol_version == LATEST_HANDSHAKE_VERSION + + result = await legacy.call_tool("greet", {"name": "world"}) + assert not result.is_error + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Hello, world!", result + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/stateless_legacy/server.py b/examples/stories/stateless_legacy/server.py new file mode 100644 index 0000000..40c82ad --- /dev/null +++ b/examples/stories/stateless_legacy/server.py @@ -0,0 +1,22 @@ +"""The one-liner HTTP deploy: one stateless ASGI app serves both protocol eras, so it exports `build_app()`.""" + +from starlette.applications import Starlette + +from mcp.server.mcpserver import MCPServer +from stories._hosting import NO_DNS_REBIND, run_app_from_args + + +def build_app() -> Starlette: + mcp = MCPServer("stateless-legacy-example") + + @mcp.tool(description="A simple greeting tool.") + def greet(name: str) -> str: + return f"Hello, {name}!" + + # stateless_http=True: no Mcp-Session-Id, fresh transport per POST — horizontally + # scalable. The same app also answers 2026-era envelope requests with no extra config. + return mcp.streamable_http_app(stateless_http=True, transport_security=NO_DNS_REBIND) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/stateless_legacy/server_lowlevel.py b/examples/stories/stateless_legacy/server_lowlevel.py new file mode 100644 index 0000000..44943ab --- /dev/null +++ b/examples/stories/stateless_legacy/server_lowlevel.py @@ -0,0 +1,38 @@ +"""The one-liner HTTP deploy (lowlevel API): Server.streamable_http_app(stateless_http=True).""" + +from typing import Any + +import mcp_types as types +from starlette.applications import Starlette + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import NO_DNS_REBIND, run_app_from_args + +GREET_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], +} + + +def build_app() -> Starlette: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool(name="greet", description="A simple greeting tool.", input_schema=GREET_INPUT_SCHEMA), + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "greet" and params.arguments is not None + return types.CallToolResult(content=[types.TextContent(text=f"Hello, {params.arguments['name']}!")]) + + server = Server("stateless-legacy-example", on_list_tools=list_tools, on_call_tool=call_tool) + return server.streamable_http_app(stateless_http=True, transport_security=NO_DNS_REBIND) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/stickynotes/README.md b/examples/stories/stickynotes/README.md new file mode 100644 index 0000000..b1d4543 --- /dev/null +++ b/examples/stories/stickynotes/README.md @@ -0,0 +1,62 @@ +# stickynotes + +The "real app" capstone: tools mutate a sticky-notes board held in the +server's lifespan context, each note is a `note:///{id}` resource, +`notifications/resources/list_changed` fires on add/remove, and `remove_all` +blocks on a form-mode elicitation so the user must explicitly confirm a +destructive clear. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.stickynotes.client + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.stickynotes.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.stickynotes.client --http --server server_lowlevel +``` + +## What to look at + +- **`client.py` `main` → `Client(target, mode=mode, elicitation_callback=..., + message_handler=...)`** — the construction is the example: callbacks are + plain constructor kwargs, and `mode=` is explicit. The scripted elicitation + answer and the `list_changed` event are locals of `main`, so every + connection starts clean. +- **`server.py` `lifespan` → `Board`** — long-lived mutable state belongs in + the lifespan context, never a module global. Tools reach it via + `ctx.request_context.lifespan_context`; this 2-hop path is interim and will + shorten to `ctx.state.*` in a later release. +- **`add_note` / `remove_note`** — `mcp.add_resource(FunctionResource(...))` + registers a concrete resource at runtime; `ctx.session.send_resource_list_changed()` + tells connected clients to re-list. **Gap:** `MCPServer` has no public + `remove_resource()` yet, so `remove_note` reaches a private attribute — do + not copy that line. `server_lowlevel.py` shows the clean equivalent: + `on_list_resources` reads the board and builds the list fresh per call, so + removal is just `board.notes.pop(...)` with no registry mutation. +- **`remove_all` → `ctx.elicit(...)`** — push-style server→client elicitation + needs a back-channel and an advertised client capability, so it only runs on + the legacy-era legs. On a modern connection there is no server→client + request channel; the modern equivalent is the multi-round-trip + `InputRequiredResult` flow (see `mrtr/`, not yet implemented). The client + branches on `client.protocol_version`. + +## Caveats + +- `list_changed` and `ctx.elicit()` are skipped on modern legs: the + notification needs a standalone stream and `ctx.elicit()` would raise + `NoBackChannelError`. `main` branches on + `client.protocol_version in HANDSHAKE_PROTOCOL_VERSIONS`. + +## Spec + +- [Tools](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) +- [Resources](https://modelcontextprotocol.io/specification/2025-11-25/server/resources) +- [Elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) + +## See also + +`tools/`, `resources/`, `legacy_elicitation/`, `lifespan/`, `standalone_get/` +(`list_changed` over the GET stream). diff --git a/examples/stories/stickynotes/__init__.py b/examples/stories/stickynotes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/stickynotes/client.py b/examples/stories/stickynotes/client.py new file mode 100644 index 0000000..56ca10f --- /dev/null +++ b/examples/stories/stickynotes/client.py @@ -0,0 +1,81 @@ +"""Drive the sticky-notes board end to end and prove `remove_all` clears only on a confirmed elicitation.""" + +import anyio +import mcp_types as types +from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS + +from mcp.client import Client, ClientRequestContext +from stories._harness import Target, run_client + + +async def main(target: Target, *, mode: str = "auto") -> None: + # Scripted reply for the server's `remove_all` elicitation; rebound between calls below. + answer = "cancel" + list_changed = anyio.Event() + + async def on_elicit(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult: + if answer == "cancel": + return types.ElicitResult(action="cancel") + return types.ElicitResult(action="accept", content={"confirm": answer == "confirm"}) + + async def on_message(message: object) -> None: + if isinstance(message, types.ResourceListChangedNotification): + list_changed.set() + + async with Client(target, mode=mode, elicitation_callback=on_elicit, message_handler=on_message) as client: + legacy = client.protocol_version in HANDSHAKE_PROTOCOL_VERSIONS + + # Add two notes. + first = await client.call_tool("add_note", {"text": "Buy milk"}) + assert first.structured_content is not None + first_id, first_uri = first.structured_content["id"], first.structured_content["uri"] + assert first_uri.startswith("note:///") + second = await client.call_tool("add_note", {"text": "Walk the dog"}) + assert second.structured_content is not None + second_id, second_uri = second.structured_content["id"], second.structured_content["uri"] + assert first_id != second_id + + # List + read — both notes appear as resources; first reads back its text. + listed = await client.list_resources() + uris = {str(r.uri) for r in listed.resources} + assert first_uri in uris and second_uri in uris, uris + read = await client.read_resource(first_uri) + assert isinstance(read.contents[0], types.TextResourceContents) + assert read.contents[0].text == "Buy milk" + + # list_changed rides the standalone stream — only deliverable on a legacy-era connection. + if legacy: + with anyio.fail_after(5): + await list_changed.wait() + + # Remove one. + removed = await client.call_tool("remove_note", {"note_id": first_id}) + assert removed.structured_content == {"result": True} + after = await client.list_resources() + assert first_uri not in {str(r.uri) for r in after.resources} + + # remove_all uses push-style elicitation: legacy-era only (modern equivalent lands with the mrtr/ story). + if not legacy: + gone = await client.call_tool("remove_note", {"note_id": second_id}) + assert gone.structured_content == {"result": True} + return + + cancelled = await client.call_tool("remove_all", {}) + assert cancelled.structured_content == {"status": "cancelled", "removed": 0} + + answer = "unchecked" + declined = await client.call_tool("remove_all", {}) + assert declined.structured_content == {"status": "declined", "removed": 0} + + answer = "confirm" + cleared = await client.call_tool("remove_all", {}) + assert cleared.structured_content == {"status": "cleared", "removed": 1} + final = await client.list_resources() + assert not [r for r in final.resources if str(r.uri).startswith("note:///")] + + empty = await client.call_tool("remove_all", {}) + assert empty.structured_content == {"status": "empty", "removed": 0} + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/stickynotes/server.py b/examples/stories/stickynotes/server.py new file mode 100644 index 0000000..4c6c9d0 --- /dev/null +++ b/examples/stories/stickynotes/server.py @@ -0,0 +1,99 @@ +"""Capstone sticky-notes board: tools mutate lifespan state, one resource per note, +`resources/list_changed` on add/remove, elicitation-guarded clear.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass, field + +from pydantic import BaseModel + +from mcp.server.mcpserver import Context, MCPServer +from mcp.server.mcpserver.resources import FunctionResource +from stories._hosting import run_server_from_args + + +@dataclass +class Board: + notes: dict[str, str] = field(default_factory=dict[str, str]) + _next: int = 1 + + def claim_id(self) -> str: + nid, self._next = str(self._next), self._next + 1 + return nid + + +class AddResult(BaseModel): + id: str + uri: str + + +class ClearResult(BaseModel): + status: str + removed: int + + +class ConfirmClear(BaseModel): + confirm: bool + + +def build_server() -> MCPServer: + @asynccontextmanager + async def lifespan(_: MCPServer) -> AsyncIterator[Board]: + yield Board() + + mcp = MCPServer("stickynotes-example", lifespan=lifespan) + + def unregister_note(note_id: str) -> None: + # DO NOT copy this line into your own server. `MCPServer` has no public + # `remove_resource()` yet (only `add_resource`), so unregistering a runtime-added + # resource has to reach a private attribute. `server_lowlevel.py` shows the clean + # shape: `on_list_resources` rebuilds the list from the board on every call, so + # removal never touches a registry at all. + mcp._resource_manager._resources.pop(f"note:///{note_id}", None) # pyright: ignore[reportPrivateUsage] + + @mcp.tool() + async def add_note(text: str, ctx: Context[Board]) -> AddResult: + """Add a sticky note and register a `note:///{id}` resource for it.""" + board = ctx.request_context.lifespan_context + note_id = board.claim_id() + uri = f"note:///{note_id}" + board.notes[note_id] = text + mcp.add_resource( + FunctionResource(uri=uri, name=f"note-{note_id}", mime_type="text/plain", fn=lambda: board.notes[note_id]) + ) + await ctx.session.send_resource_list_changed() + return AddResult(id=note_id, uri=uri) + + @mcp.tool() + async def remove_note(note_id: str, ctx: Context[Board]) -> bool: + """Remove one sticky note and unregister its resource.""" + board = ctx.request_context.lifespan_context + removed = board.notes.pop(note_id, None) is not None + if removed: + unregister_note(note_id) + await ctx.session.send_resource_list_changed() + return removed + + @mcp.tool() + async def remove_all(ctx: Context[Board]) -> ClearResult: + """Remove every note after a confirmed form-mode elicitation (handshake-era only).""" + board = ctx.request_context.lifespan_context + if not board.notes: + return ClearResult(status="empty", removed=0) + answer = await ctx.elicit(f"Remove all {len(board.notes)} note(s)? This cannot be undone.", ConfirmClear) + if answer.action == "cancel": + return ClearResult(status="cancelled", removed=0) + if answer.action != "accept" or not answer.data.confirm: + return ClearResult(status="declined", removed=0) + count = len(board.notes) + for nid in list(board.notes): + unregister_note(nid) + board.notes.clear() + await ctx.session.send_resource_list_changed() + return ClearResult(status="cleared", removed=count) + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/stickynotes/server_lowlevel.py b/examples/stories/stickynotes/server_lowlevel.py new file mode 100644 index 0000000..15a20a7 --- /dev/null +++ b/examples/stories/stickynotes/server_lowlevel.py @@ -0,0 +1,119 @@ +"""Capstone sticky-notes board on the lowlevel `Server`: handlers read lifespan state directly.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + + +@dataclass +class Board: + notes: dict[str, str] = field(default_factory=dict[str, str]) + _next: int = 1 + + def claim_id(self) -> str: + nid, self._next = str(self._next), self._next + 1 + return nid + + +CONFIRM_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"confirm": {"type": "boolean", "title": "Yes, permanently delete every sticky note"}}, + "required": ["confirm"], +} + +TOOLS = [ + types.Tool( + name="add_note", + description="Add a sticky note.", + input_schema={"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}, + ), + types.Tool( + name="remove_note", + description="Remove one sticky note.", + input_schema={"type": "object", "properties": {"note_id": {"type": "string"}}, "required": ["note_id"]}, + ), + types.Tool(name="remove_all", description="Remove every note after confirmation.", input_schema={"type": "object"}), +] + + +def _result(text: str, structured: dict[str, Any]) -> types.CallToolResult: + return types.CallToolResult(content=[types.TextContent(text=text)], structured_content=structured) + + +def build_server() -> Server[Board]: + @asynccontextmanager + async def lifespan(_: Server[Board]) -> AsyncIterator[Board]: + yield Board() + + async def list_tools( + ctx: ServerRequestContext[Board], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=TOOLS) + + async def list_resources( + ctx: ServerRequestContext[Board], params: types.PaginatedRequestParams | None + ) -> types.ListResourcesResult: + board = ctx.lifespan_context + return types.ListResourcesResult( + resources=[ + types.Resource(uri=f"note:///{nid}", name=f"note-{nid}", mime_type="text/plain") for nid in board.notes + ] + ) + + async def read_resource( + ctx: ServerRequestContext[Board], params: types.ReadResourceRequestParams + ) -> types.ReadResourceResult: + board = ctx.lifespan_context + nid = str(params.uri).removeprefix("note:///") + return types.ReadResourceResult( + contents=[types.TextResourceContents(uri=params.uri, mime_type="text/plain", text=board.notes[nid])] + ) + + async def call_tool(ctx: ServerRequestContext[Board], params: types.CallToolRequestParams) -> types.CallToolResult: + board = ctx.lifespan_context + args = params.arguments or {} + if params.name == "add_note": + nid = board.claim_id() + board.notes[nid] = args["text"] + await ctx.session.send_resource_list_changed() + return _result(f"added #{nid}", {"id": nid, "uri": f"note:///{nid}"}) + if params.name == "remove_note": + removed = board.notes.pop(args["note_id"], None) is not None + if removed: + await ctx.session.send_resource_list_changed() + return _result("removed" if removed else "not found", {"result": removed}) + if params.name == "remove_all": + if not board.notes: + return _result("empty", {"status": "empty", "removed": 0}) + answer = await ctx.session.elicit_form( + f"Remove all {len(board.notes)} note(s)? This cannot be undone.", CONFIRM_SCHEMA, ctx.request_id + ) + if answer.action == "cancel": + return _result("cancelled", {"status": "cancelled", "removed": 0}) + if answer.action != "accept" or not (answer.content or {}).get("confirm"): + return _result("declined", {"status": "declined", "removed": 0}) + count = len(board.notes) + board.notes.clear() + await ctx.session.send_resource_list_changed() + return _result(f"cleared {count}", {"status": "cleared", "removed": count}) + raise NotImplementedError + + return Server( + "stickynotes-example", + lifespan=lifespan, + on_list_tools=list_tools, + on_call_tool=call_tool, + on_list_resources=list_resources, + on_read_resource=read_resource, + ) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/streaming/README.md b/examples/stories/streaming/README.md new file mode 100644 index 0000000..e6bedb9 --- /dev/null +++ b/examples/stories/streaming/README.md @@ -0,0 +1,73 @@ +# streaming + +The three in-flight server→client channels during a tool call: **progress** +(`ctx.report_progress` → the caller's `progress_callback=`), **logging** +(`notifications/message` → the client's `logging_callback=`), and +**cancellation** (abandoning the client's awaiting scope interrupts the server +handler). One `countdown(steps)` tool emits a progress notification and a log +line per step; the client asserts both streams arrive in order, then cancels a +long call mid-flight by cancelling the enclosing `anyio.CancelScope` from +inside the progress callback (event-driven, no `sleep`). + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.streaming.client +uv run python -m stories.streaming.client --server server_lowlevel + +# HTTP — the client self-hosts the server on a free port, runs, then tears it +# down +uv run python -m stories.streaming.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.streaming.client --http --server server_lowlevel +``` + +## What to look at + +- `client.py` `main` — opens with `async with Client(target, mode=mode, + logging_callback=on_log)`. The story owns that construction; the harness only + picks the target and era. `logging_callback` is constructor-only on `Client` + (no setter after connect), so the callback and the `logs` list it fills are + closed over right above the `Client(...)` call. +- `server.py` — `ctx.report_progress(i, steps, msg)` is a silent no-op when the + caller passed no `progress_callback`; the SDK reads the token from the + request's `_meta` for you. The log notification is sent via the raw + `session.send_notification(...)` because the `ctx.log()` / `ctx.info()` + shorthands are deprecated (SEP-2577) with no non-deprecated replacement yet. + `related_request_id=` keeps the log on this request's response stream — over + streamable HTTP an unrelated notification would ride the standalone GET + stream instead. +- `server.py` — `ctx.request_context.session` / `ctx.request_context.request_id` + is the interim 2-hop path; a later release will shorten these. +- `server.py` — the `except anyio.get_cancelled_exc_class(): raise` block is + where a real handler would release resources before re-raising. **Never + swallow** the cancellation exception. +- `client.py` — cancellation is just cancelling the `anyio` scope around + `await client.call_tool(...)`; the SDK sends `notifications/cancelled` for + you on stateful transports. There is no `client.cancel(request_id)` API. +- `server_lowlevel.py` — the same wire contract built by hand against + `ServerRequestContext.session` directly. + +## Caveats + +- **Logging is deprecated** in the 2026-07-28 protocol (SEP-2577); functional + through the deprecation window. Migration: write to stderr or emit + OpenTelemetry instead of `notifications/message`. It is shown here because + servers still need to support 2025-era clients during that window. Progress + and cancellation are **not** deprecated. TODO(maxisbey): revisit before beta. +- When a request is cancelled the server currently replies with + `ErrorData(code=0, message="Request cancelled")`; the spec says it should not + reply at all. The client never observes it (its awaiting task is already + cancelled), so this story does not assert on the reply. + +## Spec + +[Progress](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress), +[cancellation](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation), +[logging](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging) + +## See also + +`parallel_calls/` (concurrent in-flight calls), `error_handling/` (the +cancellation error path), `tools/` (the basics this builds on). diff --git a/examples/stories/streaming/__init__.py b/examples/stories/streaming/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/streaming/client.py b/examples/stories/streaming/client.py new file mode 100644 index 0000000..e584b4c --- /dev/null +++ b/examples/stories/streaming/client.py @@ -0,0 +1,54 @@ +"""Asserts progress + log notifications arrive in order, then cancels a call mid-flight.""" + +import anyio +from mcp_types import LoggingMessageNotificationParams + +from mcp.client import Client +from stories._harness import Target, run_client + + +async def main(target: Target, *, mode: str = "auto") -> None: + # `logging_callback` is constructor-only on `Client`, so the list it fills + # has to exist before the connection does. + logs: list[LoggingMessageNotificationParams] = [] + + async def on_log(params: LoggingMessageNotificationParams) -> None: + logs.append(params) + + async with Client(target, mode=mode, logging_callback=on_log) as client: + # ── progress + logging: a short countdown delivers exactly `steps` of each, in order ── + updates: list[tuple[float, float | None, str | None]] = [] + + async def collect(progress: float, total: float | None, message: str | None) -> None: + updates.append((progress, total, message)) + + result = await client.call_tool("countdown", {"steps": 3}, progress_callback=collect) + assert result.structured_content == {"completed": 3, "total": 3}, result + assert updates == [(1.0, 3.0, "step 1/3"), (2.0, 3.0, "step 2/3"), (3.0, 3.0, "step 3/3")] + assert [(m.level, m.logger, m.data) for m in logs] == [ + ("info", "countdown", "step 1/3"), + ("info", "countdown", "step 2/3"), + ("info", "countdown", "step 3/3"), + ] + + # ── cancellation: abandon the awaiting scope once the call is provably in flight ── + in_flight = anyio.Event() + with anyio.fail_after(5): + with anyio.CancelScope() as scope: + + async def cancel_once_in_flight(progress: float, total: float | None, message: str | None) -> None: + in_flight.set() + scope.cancel() + + await client.call_tool("countdown", {"steps": 1_000}, progress_callback=cancel_once_in_flight) + + assert in_flight.is_set(), "the call must have started before it was cancelled" + assert scope.cancelled_caught, "abandoning the scope should have cancelled the in-flight call" + + # The session survives cancellation: a follow-up call still works. + after = await client.call_tool("countdown", {"steps": 1}, progress_callback=collect) + assert after.structured_content == {"completed": 1, "total": 1} + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/streaming/server.py b/examples/stories/streaming/server.py new file mode 100644 index 0000000..ced5987 --- /dev/null +++ b/examples/stories/streaming/server.py @@ -0,0 +1,40 @@ +"""Progress, in-flight logging, and cancellation from a single long-running tool.""" + +import anyio +import mcp_types as types + +from mcp.server.mcpserver import Context, MCPServer +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer("streaming-example") + + @mcp.tool() + async def countdown(steps: int, ctx: Context) -> dict[str, int]: + """Emit one progress + one log notification per step; observes cancellation.""" + try: + for i in range(1, steps + 1): + await ctx.report_progress(float(i), float(steps), f"step {i}/{steps}") + # No non-deprecated logging helper on Context yet, so send the raw + # notification. `related_request_id` keeps it on this request's response + # stream (matters over streamable HTTP). + await ctx.request_context.session.send_notification( + types.LoggingMessageNotification( + params=types.LoggingMessageNotificationParams( + level="info", logger="countdown", data=f"step {i}/{steps}" + ) + ), + related_request_id=ctx.request_context.request_id, + ) + except anyio.get_cancelled_exc_class(): + # The client abandoned the call. Release resources here, then re-raise so + # the dispatcher unwinds the request — never swallow cancellation. + raise + return {"completed": steps, "total": steps} + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/streaming/server_lowlevel.py b/examples/stories/streaming/server_lowlevel.py new file mode 100644 index 0000000..6d9add0 --- /dev/null +++ b/examples/stories/streaming/server_lowlevel.py @@ -0,0 +1,69 @@ +"""Progress, in-flight logging, and cancellation against the low-level Server.""" + +from typing import Any + +import anyio +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + +COUNTDOWN_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"steps": {"type": "integer"}}, + "required": ["steps"], +} + + +def build_server() -> Server[Any]: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="countdown", + description="Emit one progress + one log notification per step; observes cancellation.", + input_schema=COUNTDOWN_INPUT_SCHEMA, + ) + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "countdown" and params.arguments is not None + steps = int(params.arguments["steps"]) + try: + for i in range(1, steps + 1): + await ctx.session.report_progress(float(i), float(steps), f"step {i}/{steps}") + await ctx.session.send_notification( + types.LoggingMessageNotification( + params=types.LoggingMessageNotificationParams( + level="info", logger="countdown", data=f"step {i}/{steps}" + ) + ), + related_request_id=ctx.request_id, + ) + except anyio.get_cancelled_exc_class(): + raise + return types.CallToolResult( + content=[types.TextContent(text=f"completed {steps}/{steps}")], + structured_content={"completed": steps, "total": steps}, + ) + + async def set_logging_level( + ctx: ServerRequestContext[Any], params: types.SetLevelRequestParams + ) -> types.EmptyResult: + """Registered so the server advertises the `logging` capability; never called.""" + raise NotImplementedError + + return Server( # pyright: ignore[reportDeprecated] + "streaming-example", + on_list_tools=list_tools, + on_call_tool=call_tool, + on_set_logging_level=set_logging_level, + ) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/subscriptions/README.md b/examples/stories/subscriptions/README.md new file mode 100644 index 0000000..bc309d9 --- /dev/null +++ b/examples/stories/subscriptions/README.md @@ -0,0 +1,62 @@ +# subscriptions + +Server-originated change notifications on the 2026-07-28 protocol. A client +opens one `subscriptions/listen` request whose response **is** the stream; the +server publishes with `ctx.notify_resource_updated(uri)` / +`ctx.notify_tools_changed()` and the SDK does the wire work (ack-first, +per-stream filtering, subscription-id tagging). Replaces the handshake-era +`resources/subscribe` + standalone-GET notification path. + +The client opens the stream with `client.listen(...)`, edits a note it did +not subscribe to (silence), edits the one it did (a typed `ResourceUpdated`), +registers a tool at runtime (a typed `ToolsListChanged`, then re-lists and +calls it), and finally leaves the `async with` block, which ends the +subscription while the connection lives on. + +## Run it + +```bash +# HTTP: the client self-hosts the server on a free port, runs, then tears it +# down (subscriptions/listen is 2026-era only) +uv run python -m stories.subscriptions.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.subscriptions.client --http --server server_lowlevel +``` + +## What to look at + +- `client.py`: the whole subscription is one context manager, + `async with client.listen(...) as sub`. Entering waits for the server's + acknowledgment, so `sub.honored` is already in hand on the first line of the + block. Events arrive as typed values from `anext(sub)`; the edit to the + unsubscribed note never shows up, because the filter is enforced + server-side. Leaving the block ends the subscription (over HTTP the SDK + closes that request's response stream) and the session carries on, which the + final `search` call proves. +- `server.py`: publishing is one `await ctx.notify_*()` line per change; the + filter, the tagging, and the ack ordering are the SDK's job. Publishing with + no subscribers is a no-op. +- `server_lowlevel.py`: the same machinery held by hand: an + `InMemorySubscriptionBus`, handlers that `await bus.publish(...)`, and + `ListenHandler(bus)` passed as `on_subscriptions_listen=`. A multi-replica + deployment swaps the bus for one backed by its own pub/sub + (`MCPServer(subscriptions=...)` on the high-level server). + +## Caveats + +- 2026-era only: on a 2025 connection the method does not exist (clients there + use `resources/subscribe` and unsolicited notifications instead), so the + story pins the modern era and has no legacy leg. +- No replay: events published while no stream is open are not queued. The + contract after a dropped stream is re-listen and re-fetch. + +## Spec + +[Subscriptions, basic utilities](https://modelcontextprotocol.io/specification/draft/basic/utilities/subscriptions) + +## See also + +`streaming/` (request-scoped notifications), `events/` (the events extension +on top of this channel, deferred), and the narrative versions: +`docs/handlers/subscriptions.md` (server) and `docs/client/subscriptions.md` +(client). diff --git a/examples/stories/subscriptions/__init__.py b/examples/stories/subscriptions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/subscriptions/client.py b/examples/stories/subscriptions/client.py new file mode 100644 index 0000000..d2053aa --- /dev/null +++ b/examples/stories/subscriptions/client.py @@ -0,0 +1,43 @@ +"""Open a `subscriptions/listen` stream, watch one URI and the tool list, then close it.""" + +import anyio +import mcp_types as types + +from mcp.client import Client +from mcp.client.subscriptions import ResourceUpdated, ToolsListChanged +from stories._harness import Target, run_client + + +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + before = await client.list_tools() + assert "search" not in {tool.name for tool in before.tools} + + async with client.listen(tools_list_changed=True, resource_subscriptions=["note://todo"]) as sub: + # ── entering waited for the ack: the honored filter is already in hand ── + assert sub.honored.tools_list_changed is True + assert sub.honored.resource_subscriptions == ["note://todo"] + + # ── exact-URI filtering: an unsubscribed note edit stays silent ── + await client.call_tool("edit_note", {"name": "journal", "text": "day two"}) + # ── the subscribed URI delivers ── + await client.call_tool("edit_note", {"name": "todo", "text": "water plants"}) + with anyio.fail_after(10): + event = await anext(sub) + assert event == ResourceUpdated(uri="note://todo"), "the journal edit must not have been delivered" + + # ── a runtime tool registration announces itself ── + await client.call_tool("enable_search", {}) + with anyio.fail_after(10): + assert await anext(sub) == ToolsListChanged() + + # ── leaving the block closed the stream; the session lives on ── + tools = await client.list_tools() + assert "search" in {tool.name for tool in tools.tools} + result = await client.call_tool("search", {"query": "water"}) + content = result.content[0] + assert isinstance(content, types.TextContent) and content.text == "todo", result + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/subscriptions/server.py b/examples/stories/subscriptions/server.py new file mode 100644 index 0000000..a248bf0 --- /dev/null +++ b/examples/stories/subscriptions/server.py @@ -0,0 +1,41 @@ +"""A notebook whose edits and tool changes reach `subscriptions/listen` streams.""" + +from mcp.server.mcpserver import Context, MCPServer +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer("subscriptions-example") + notes = {"todo": "buy milk", "journal": "day one"} + + @mcp.resource("note://{name}") + def note(name: str) -> str: + return notes[name] + + @mcp.tool() + async def edit_note(name: str, text: str, ctx: Context) -> str: + """Replace a note's text and tell subscribers that URI changed.""" + notes[name] = text + await ctx.notify_resource_updated(f"note://{name}") + return "saved" + + def search(query: str) -> list[str]: + return [name for name, text in notes.items() if query in text] + + enabled = False + + @mcp.tool() + async def enable_search(ctx: Context) -> str: + """Register the `search` tool at runtime and tell subscribers the list changed.""" + nonlocal enabled + if not enabled: + enabled = True + mcp.add_tool(search) + await ctx.notify_tools_changed() + return "search is live" + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/subscriptions/server_lowlevel.py b/examples/stories/subscriptions/server_lowlevel.py new file mode 100644 index 0000000..6d9da18 --- /dev/null +++ b/examples/stories/subscriptions/server_lowlevel.py @@ -0,0 +1,72 @@ +"""The same notebook against the low-level Server: an explicit bus + ListenHandler.""" + +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from mcp.server.subscriptions import ( + InMemorySubscriptionBus, + ListenHandler, + ResourceUpdated, + ToolsListChanged, +) +from stories._hosting import run_server_from_args + +EDIT_NOTE_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"name": {"type": "string"}, "text": {"type": "string"}}, + "required": ["name", "text"], +} +EMPTY_SCHEMA: dict[str, Any] = {"type": "object", "properties": {}} +SEARCH_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], +} + + +def build_server() -> Server[Any]: + # The bus lives wherever your handlers can reach it; the lifespan is the + # natural home in a bigger app. The closure is enough here. + bus = InMemorySubscriptionBus() + notes = {"todo": "buy milk", "journal": "day one"} + search_enabled = False + + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + tools = [ + types.Tool(name="edit_note", description="Replace a note's text.", input_schema=EDIT_NOTE_SCHEMA), + types.Tool(name="enable_search", description="Register the search tool.", input_schema=EMPTY_SCHEMA), + ] + if search_enabled: + tools.append(types.Tool(name="search", description="Find notes.", input_schema=SEARCH_SCHEMA)) + return types.ListToolsResult(tools=tools) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + nonlocal search_enabled + args = params.arguments or {} + if params.name == "edit_note": + notes[args["name"]] = args["text"] + await bus.publish(ResourceUpdated(uri=f"note://{args['name']}")) + return types.CallToolResult(content=[types.TextContent(text="saved")]) + if params.name == "enable_search": + search_enabled = True + await bus.publish(ToolsListChanged()) + return types.CallToolResult(content=[types.TextContent(text="search is live")]) + assert params.name == "search" and search_enabled + matches = [name for name, text in notes.items() if args["query"] in text] + return types.CallToolResult(content=[types.TextContent(text=", ".join(matches))]) + + return Server( + "subscriptions-example", + on_list_tools=list_tools, + on_call_tool=call_tool, + on_subscriptions_listen=ListenHandler(bus), + ) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/tasks/README.md b/examples/stories/tasks/README.md new file mode 100644 index 0000000..d1956d1 --- /dev/null +++ b/examples/stories/tasks/README.md @@ -0,0 +1,24 @@ +# tasks + +Task-augmented execution: a requestor augments a `tools/call` with a `task`, the +receiver returns a `CreateTaskResult` immediately, and the requestor polls +`tasks/get` and retrieves the deferred result. + +**Status: deferred.** Tasks ship in 2026-07-28 as +[SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/seps/2663-tasks-extension.md), +an `io.modelcontextprotocol/tasks` extension that is wire-incompatible with the +2025-11-25 in-core design still carried (types-only) in `mcp_types`. The runtime +needs to be built to the SEP — server-decided augmentation (ignoring the legacy +`params.task`), the `{tasks/get, tasks/update, tasks/cancel}` method set, the +`resultType: "task"` envelope, `execution.taskSupport` gating, and `ttlMs` +fields — so it lands in a separate PR with the conformance `tasks-*` scenarios +wired in. + +## Spec + +[SEP-2663 — Tasks extension](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/seps/2663-tasks-extension.md) +· [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133) + +## See also + +`apps/` (the additive half of the extension API). diff --git a/examples/stories/tools/README.md b/examples/stories/tools/README.md new file mode 100644 index 0000000..996fa0c --- /dev/null +++ b/examples/stories/tools/README.md @@ -0,0 +1,39 @@ +# tools + +**Start here.** Register tools with `@mcp.tool()`; the SDK infers the JSON +input schema from type hints, the output schema from the return annotation, and +returns `structuredContent` alongside text. `ToolAnnotations` carries +behavioural hints (`readOnlyHint`, `idempotentHint`) the host can show to +users. The client lists tools, inspects schemas + annotations, calls both, and +asserts structured output. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.tools.client + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.tools.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.tools.client --http --server server_lowlevel +``` + +## What to look at + +- `server.py` `calc` — `Literal[...]` and `BaseModel` in the signature become + the tool's `inputSchema` / `outputSchema` with zero hand-written JSON. +- `server.py` `echo` — `structured_output=False` opts out of schema inference + for a plain text-only tool. +- `server_lowlevel.py` — the same wire contract built by hand: this is what + `MCPServer` generates for you. + +## Spec + +[Tools — server features](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) + +## See also + +`schema_validators/` (every input-schema source: pydantic / TypedDict / +dataclass / dict), `error_handling/` (`is_error` vs protocol error), +`streaming/` (progress mid-call). diff --git a/examples/stories/tools/__init__.py b/examples/stories/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/stories/tools/client.py b/examples/stories/tools/client.py new file mode 100644 index 0000000..74e1ab4 --- /dev/null +++ b/examples/stories/tools/client.py @@ -0,0 +1,32 @@ +"""List tools, inspect schemas + annotations, call both tools, assert structured output.""" + +from mcp_types import TextContent + +from mcp.client import Client +from stories._harness import Target, run_client + + +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + listed = await client.list_tools() + by_name = {t.name: t for t in listed.tools} + assert set(by_name) == {"calc", "echo"} + + calc = by_name["calc"] + assert calc.annotations is not None and calc.annotations.read_only_hint is True + assert calc.annotations.idempotent_hint is True + assert calc.output_schema is not None + assert set(calc.input_schema.get("required", ())) >= {"op", "a", "b"} + assert by_name["echo"].output_schema is None + + result = await client.call_tool("calc", {"op": "add", "a": 2, "b": 3}) + assert not result.is_error + assert result.structured_content == {"op": "add", "result": 5.0}, result + + echoed = await client.call_tool("echo", {"text": "hi"}) + assert echoed.structured_content is None + assert isinstance(echoed.content[0], TextContent) and echoed.content[0].text == "hi" + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/tools/server.py b/examples/stories/tools/server.py new file mode 100644 index 0000000..a1f035c --- /dev/null +++ b/examples/stories/tools/server.py @@ -0,0 +1,37 @@ +"""Tools primitive: register, list, call, structured output, annotations.""" + +from typing import Literal + +from mcp_types import ToolAnnotations +from pydantic import BaseModel + +from mcp.server.mcpserver import MCPServer +from stories._hosting import run_server_from_args + + +class CalcResult(BaseModel): + op: str + result: float + + +def build_server() -> MCPServer: + mcp = MCPServer("tools-example") + + @mcp.tool( + title="Calculator", + description="Apply an arithmetic operation to two numbers.", + annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True), + ) + def calc(op: Literal["add", "sub", "mul"], a: float, b: float) -> CalcResult: + result = a + b if op == "add" else a - b if op == "sub" else a * b + return CalcResult(op=op, result=result) + + @mcp.tool(description="Echo the input back as plain text.", structured_output=False) + def echo(text: str) -> str: + return text + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/tools/server_lowlevel.py b/examples/stories/tools/server_lowlevel.py new file mode 100644 index 0000000..e6c4c05 --- /dev/null +++ b/examples/stories/tools/server_lowlevel.py @@ -0,0 +1,72 @@ +"""Tools primitive (lowlevel API): hand-built Tool descriptors and CallToolResult.""" + +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + +CALC_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "op": {"type": "string", "enum": ["add", "sub", "mul"]}, + "a": {"type": "number"}, + "b": {"type": "number"}, + }, + "required": ["op", "a", "b"], +} +CALC_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"op": {"type": "string"}, "result": {"type": "number"}}, + "required": ["op", "result"], +} +ECHO_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], +} + + +def build_server() -> Server[Any]: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="calc", + title="Calculator", + description="Apply an arithmetic operation to two numbers.", + input_schema=CALC_INPUT_SCHEMA, + output_schema=CALC_OUTPUT_SCHEMA, + annotations=types.ToolAnnotations(read_only_hint=True, idempotent_hint=True), + ), + types.Tool( + name="echo", + description="Echo the input back as plain text.", + input_schema=ECHO_INPUT_SCHEMA, + ), + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.arguments is not None + if params.name == "calc": + op, a, b = params.arguments["op"], float(params.arguments["a"]), float(params.arguments["b"]) + result = a + b if op == "add" else a - b if op == "sub" else a * b + payload = {"op": op, "result": result} + return types.CallToolResult( + content=[types.TextContent(text=f"{a} {op} {b} = {result}")], + structured_content=payload, + ) + if params.name == "echo": + return types.CallToolResult(content=[types.TextContent(text=str(params.arguments["text"]))]) + raise NotImplementedError + + return Server("tools-example", on_list_tools=list_tools, on_call_tool=call_tool) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..5b3f777 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,201 @@ +site_name: MCP Python SDK +site_description: The official Python SDK for the Model Context Protocol + +repo_name: modelcontextprotocol/python-sdk +repo_url: https://github.com/modelcontextprotocol/python-sdk +edit_uri: edit/main/docs/ +site_url: https://py.sdk.modelcontextprotocol.io/v2/ + +# TODO(Marcelo): Add Anthropic copyright? +# copyright: © Model Context Protocol 2025 to present + +# The "API Reference" entry is a placeholder: `scripts/docs/gen_ref_pages.py` +# generates the `docs/api/` tree and `scripts/docs/build_config.py` splices the +# real nested nav in before the build. See `scripts/docs/build.sh`. +nav: + - MCP Python SDK: index.md + - "What's new in v2": whats-new.md + - Get started: + - get-started/index.md + - Installation: get-started/installation.md + - First steps: get-started/first-steps.md + - Connect to a real host: get-started/real-host.md + - Testing: get-started/testing.md + - Servers: + - servers/index.md + - Tools: servers/tools.md + - Structured Output: servers/structured-output.md + - Resources: servers/resources.md + - URI templates: servers/uri-templates.md + - Prompts: servers/prompts.md + - Completions: servers/completions.md + - "Images, audio & icons": servers/media.md + - Handling errors: servers/handling-errors.md + - Inside your handler: + - handlers/index.md + - The Context: handlers/context.md + - Dependencies: handlers/dependencies.md + - Lifespan: handlers/lifespan.md + - Elicitation: handlers/elicitation.md + - Multi-round-trip requests: handlers/multi-round-trip.md + - Sampling and roots: handlers/sampling-and-roots.md + - Progress: handlers/progress.md + - Logging: handlers/logging.md + - Subscriptions: handlers/subscriptions.md + - Running your server: + - run/index.md + - Add to an existing app: run/asgi.md + - Deploy & scale: run/deploy.md + - Authorization: run/authorization.md + - OpenTelemetry: run/opentelemetry.md + - Serving legacy clients: run/legacy-clients.md + - Clients: + - client/index.md + - Callbacks: client/callbacks.md + - Transports: client/transports.md + - OAuth: client/oauth-clients.md + - Identity assertion: client/identity-assertion.md + - Multiple servers: client/session-groups.md + - Subscriptions: client/subscriptions.md + - Caching: client/caching.md + - Protocol versions: protocol-versions.md + - Deprecated features: deprecated.md + - Advanced: + - advanced/index.md + - The low-level Server: advanced/low-level-server.md + - Pagination: advanced/pagination.md + - Middleware: advanced/middleware.md + - Extensions: advanced/extensions.md + - MCP Apps: advanced/apps.md + - Troubleshooting: troubleshooting.md + - Migration Guide: migration.md + - API Reference: api/ + +theme: + name: "material" + custom_dir: docs/.overrides + font: + text: Inter + code: JetBrains Mono + icon: + logo: mcp + favicon: favicon.svg + palette: + - media: "(prefers-color-scheme)" + scheme: default + primary: black + accent: black + toggle: + icon: material/lightbulb + name: "Switch to light mode" + - media: "(prefers-color-scheme: light)" + scheme: default + primary: black + accent: black + toggle: + icon: material/lightbulb-outline + name: "Switch to dark mode" + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: black + accent: white + toggle: + icon: material/lightbulb-auto-outline + name: "Switch to system preference" + features: + - search.suggest + - search.highlight + - content.tabs.link + - content.code.annotate + - content.code.copy + - content.code.select + - navigation.footer + - navigation.indexes + - navigation.instant + - navigation.instant.prefetch + - navigation.instant.progress + - navigation.path + - navigation.prune + - navigation.sections + - navigation.top + - navigation.tracking + - toc.follow + +extra_css: + - extra.css + +markdown_extensions: + - tables + - abbr + - admonition + - attr_list + - def_list + - footnotes + - md_in_html + - pymdownx.betterem + - pymdownx.details + - pymdownx.caret + - pymdownx.critic + - pymdownx.mark + # Code examples live as complete, importable, tested files under `docs_src/` + # and are included into pages with `--8<-- "docs_src//tutorialNNN.py"` + # (resolved against the repo root, which is the build's working directory). + # `check_paths: true` turns a renamed/deleted example into a build failure + # instead of a silently empty code block. + - pymdownx.snippets: + base_path: [.] + check_paths: true + - pymdownx.tilde + - pymdownx.inlinehilite + - pymdownx.highlight: + pygments_lang_class: true + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: pymdownx.superfences.fence_code_format + # Zensical re-implements the emoji extension; the generator/index functions + # live under `zensical.extensions.emoji`, not `material.extensions.emoji`. + - pymdownx.emoji: + emoji_index: zensical.extensions.emoji.twemoji + emoji_generator: zensical.extensions.emoji.to_svg + options: + custom_icons: + - docs/.overrides/.icons + - pymdownx.tabbed: + alternate_style: true + - pymdownx.tasklist: + custom_checkbox: true + - sane_lists # this means you can start a list from any number + +# Zensical natively watches these beyond docs/: page content is assembled +# from src/ (mkdocstrings) and docs_src/ (snippet includes), so serve +# live-reload must react to both. +watch: + - src + - docs_src + +# Zensical natively re-implements `search`, `glightbox` and `mkdocstrings`; it +# does not run arbitrary MkDocs plugins or hooks. The former `gen-files`, +# `literate-nav` and `llms_txt` hook are handled by the standalone scripts +# under `scripts/docs/` (see scripts/docs/build.sh). The `social` plugin was +# dropped: Zensical has no social-card support, and the cards were gated on +# ENABLE_SOCIAL_CARDS, which no workflow ever set. +plugins: + - search + - glightbox + - mkdocstrings: + handlers: + python: + paths: [src, src/mcp-types] + options: + relative_crossrefs: true + members_order: source + separate_signature: true + show_signature_annotations: true + signature_crossrefs: true + group_by_category: false + inventories: + - url: https://docs.python.org/3/objects.inv + - url: https://docs.pydantic.dev/latest/objects.inv + - url: https://typing-extensions.readthedocs.io/en/latest/objects.inv diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7c4e4ce --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,315 @@ +[project] +name = "mcp" +dynamic = ["version", "dependencies"] +description = "Model Context Protocol SDK" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +maintainers = [ + { name = "David Soria Parra", email = "davidsp@anthropic.com" }, + { name = "Marcelo Trylesinski", email = "marcelotryle@gmail.com" }, + { name = "Max Isbey", email = "maxisbey@anthropic.com" }, + { name = "Felix Weinberger", email = "fweinberger@anthropic.com" }, +] +keywords = ["mcp", "llm", "automation"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +[project.optional-dependencies] +rich = ["rich>=13.9.4"] +cli = ["typer>=0.16.0", "python-dotenv>=1.0.0"] + +[project.scripts] +mcp = "mcp.cli:app [cli]" + +[tool.uv] +default-groups = ["dev", "docs"] +required-version = ">=0.9.5" +# PEP 517 build isolation fetches [build-system].requires (and transitives) at +# floating-latest with no hash check on every fresh sync; uv does not lock them +# (astral-sh/uv#5190). Pinning here narrows that to known-good versions. Covers +# the workspace builds (hatchling + uv-dynamic-versioning) and the legacy +# setuptools fallback used by the strict-no-cover git dep. +build-constraint-dependencies = [ + "hatchling==1.29.0", + "uv-dynamic-versioning==0.14.0", + "dunamai==1.26.1", + "jinja2==3.1.6", + "markupsafe==3.0.3", + "packaging==26.1", + "pathspec==1.0.4", + "pluggy==1.6.0", + "tomlkit==0.14.0", + "trove-classifiers==2026.1.14.14", + "setuptools==82.0.1", +] + +[dependency-groups] +dev = [ + # We add mcp[cli] so `uv sync` considers the extras. + "mcp[cli]", + "mcp-example-stories", + "tomli>=2.0; python_version < '3.11'", + "pyright>=1.1.400", + "pytest>=8.4.0", + "ruff>=0.8.5", + "trio>=0.26.2", + "pytest-flakefinder>=1.1.0", + "pytest-xdist>=3.6.1", + "pytest-examples>=0.0.14", + "pytest-pretty>=1.2.0", + "inline-snapshot>=0.23.0", + "dirty-equals>=0.9.0", + "coverage[toml]>=7.10.7,<=7.13", + "pillow>=12.0", + "strict-no-cover", + "logfire>=3.0.0", + "opentelemetry-sdk>=1.39.1", +] +docs = [ + # Zensical is the Material team's successor to MkDocs; it natively + # re-implements search, glightbox and mkdocstrings but runs no arbitrary + # MkDocs plugins or hooks, so the API reference (formerly gen-files + + # literate-nav) and llms.txt (formerly a hook) are generated by the + # standalone scripts under scripts/docs/. See scripts/docs/build.sh. + # 0.0.48 fixed relative/scoped cross-references for mkdocstrings-python + # (which the mkdocstrings config in mkdocs.yml relies on) but broke + # search; 0.0.50 fixes it. The toolchain is pinned exactly: Zensical is + # pre-1.0 and the build guards key on its rendering behavior, so bumps + # should be deliberate. + "zensical==0.0.50", + # Zensical's mkdocstrings compatibility layer targets the mkdocstrings 1.x / + # mkdocstrings-python 2.0.5+ API (griffe 2 / griffelib); the older + # mkdocstrings 0.30 / python 2.0.1 line renders API pages with an + # unregistered-autorefs KeyError under Zensical. + "mkdocstrings==1.0.4", + "mkdocstrings-python==2.0.5", + # scripts/docs/build_config.py and llms_txt.py read mkdocs.yml directly. + "pyyaml>=6.0.2", + # gen_ref_pages.py imports griffe directly. griffelib is not a typo: it is + # griffe's successor distribution (same author) and still imports as + # `griffe`; the old `griffe` distribution is the incompatible 1.x line. + "griffelib==2.1.0", +] +codegen = ["datamodel-code-generator==0.57.0"] + +[build-system] +requires = ["hatchling", "uv-dynamic-versioning"] +build-backend = "hatchling.build" + +[tool.hatch.version] +source = "uv-dynamic-versioning" + +[tool.uv-dynamic-versioning] +vcs = "git" +style = "pep440" +bump = true + +[tool.hatch.metadata.hooks.uv-dynamic-versioning] +dependencies = [ + # anyio < 4.10 triggers a compile-time SyntaxWarning on Python 3.14 (PEP 765, + # "'return' in a 'finally' block"); for stdio servers it lands on the child's + # stderr (agronholm/anyio#816, fixed in 4.10). + "anyio>=4.10; python_version >= '3.14'", + "anyio>=4.9; python_version < '3.14'", + "httpx>=0.27.1,<1.0.0", + "httpx-sse>=0.4", + "mcp-types=={{ version }}", + "pydantic>=2.12.0", + "starlette>=0.48.0; python_version >= '3.14'", + "starlette>=0.27; python_version < '3.14'", + "python-multipart>=0.0.9", + "sse-starlette>=3.0.0", + "pydantic-settings>=2.5.2", + "uvicorn>=0.31.1; sys_platform != 'emscripten'", + "jsonschema>=4.20.0", + "pywin32>=311; sys_platform == 'win32'", + "pyjwt[crypto]>=2.10.1", + "typing-extensions>=4.13.0", + "typing-inspection>=0.4.1", + "opentelemetry-api>=1.28.0", +] + +[project.urls] +Homepage = "https://modelcontextprotocol.io" +Documentation = "https://py.sdk.modelcontextprotocol.io/v2/" +Repository = "https://github.com/modelcontextprotocol/python-sdk" +Issues = "https://github.com/modelcontextprotocol/python-sdk/issues" + +[tool.hatch.build.targets.wheel] +packages = ["src/mcp"] + +[tool.pyright] +typeCheckingMode = "strict" +include = [ + "src/mcp", + "src/mcp-types/mcp_types", + "tests", + "docs_src", + "examples/stories", + "examples/servers", + "examples/snippets", + "examples/clients", +] +venvPath = "." +venv = ".venv" +# `stories` is a workspace package rooted at examples/; the IDE language server +# does not always pick up the editable-install .pth, so resolve it statically. +extraPaths = ["examples"] +# The FastAPI style of using decorators in tests gives a `reportUnusedFunction` error. +# See https://github.com/microsoft/pyright/issues/7771 for more details. +# TODO(Marcelo): We should remove `reportPrivateUsage = false`. The idea is that we should test the workflow that uses +# those private functions instead of testing the private functions directly. It makes it easier to maintain the code source +# and refactor code that is not public. +executionEnvironments = [ + { root = "tests", extraPaths = [ + ".", + "examples", + ], reportUnusedFunction = false, reportPrivateUsage = false }, + { root = "examples/stories", extraPaths = [ + "examples", + ], reportUnusedFunction = false }, + # The `mcp-example-stories` editable install puts `examples/` on sys.path, + # which defeats pyright's auto-detection of `simple-auth/` as a package + # root (it's the one server example that imports itself by absolute name). + { root = "examples/servers", extraPaths = [ + "examples/servers/simple-auth", + ], reportUnusedFunction = false }, + # docs_src/ holds the complete, runnable code examples included into docs/*.md. + # Decorated (@mcp.tool/...) module-level functions are never called by name. + { root = "docs_src", reportUnusedFunction = false }, +] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[tool.ruff.lint] +select = [ + "C4", # flake8-comprehensions + "C90", # mccabe + "D212", # pydocstyle: multi-line docstring summary should start at the first line + "E", # pycodestyle + "F", # pyflakes + "I", # isort + "PERF", # Perflint + "UP", # pyupgrade + "TID251", # https://docs.astral.sh/ruff/rules/banned-api/ +] +ignore = ["PERF203"] + +[tool.ruff.lint.flake8-tidy-imports.banned-api] +"pydantic.RootModel".msg = "Use `pydantic.TypeAdapter` instead." + + +[tool.ruff.lint.mccabe] +max-complexity = 24 # Default is 10 + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401"] +# Generated by scripts/gen_surface_types.py: raw datamodel-codegen output (TID251 lifts the repo-wide RootModel ban for these generated validators). +"src/mcp-types/mcp_types/v*/__init__.py" = ["D212", "E501", "I001", "TID251", "UP007", "UP037"] +"tests/server/mcpserver/test_func_metadata.py" = ["E501"] +"tests/shared/test_progress_notifications.py" = ["PLW0603"] + +[tool.ruff.lint.pylint] +allow-magic-value-types = ["bytes", "float", "int", "str"] +max-args = 23 # Default is 5 +max-branches = 23 # Default is 12 +max-returns = 13 # Default is 6 +max-statements = 102 # Default is 50 + +[tool.uv.workspace] +members = ["src/mcp-types", "examples", "examples/clients/*", "examples/servers/*", "examples/snippets"] + +[tool.uv.sources] +mcp = { workspace = true } +mcp-example-stories = { workspace = true } +mcp-types = { workspace = true } +strict-no-cover = { git = "https://github.com/pydantic/strict-no-cover" } + +[tool.pytest.ini_options] +log_cli = true +xfail_strict = true +markers = [ + "requirement(id): links a test to the entry in tests/interaction/_requirements.py it exercises", +] +addopts = """ + --color=yes + --capture=fd + -p anyio + -p examples +""" +filterwarnings = [ + "error", + # pywin32 internal deprecation warning + "ignore:getargs.*The 'u' format is deprecated:DeprecationWarning", + # SEP-2577 deprecates the roots/sampling/logging methods; the SDK still calls + # them internally (e.g. `ctx.debug` -> `log` -> `send_log_message`), so the + # advisory warning is silenced. Tests asserting it opt back in with pytest.warns. + "ignore:.*is deprecated as of 2026-07-28 \\(SEP-2577\\).:mcp.MCPDeprecationWarning", + # 2026-07-28 restricts progress to server->client; the client send path is + # advisory-deprecated and a handful of tests still exercise it. + "ignore:Client-to-server progress is deprecated as of 2026-07-28.*:mcp.MCPDeprecationWarning", + # 2026-07-28 drops ping; Client.send_ping() is advisory-deprecated and the + # legacy interaction/transport tests still drive it. + "ignore:ping is removed as of 2026-07-28.*:mcp.MCPDeprecationWarning", +] + +[tool.markdown.lint] +default = true +MD004 = false # ul-style - Unordered list style +MD007.indent = 2 # ul-indent - Unordered list indentation +MD013 = false # line-length - Line length +MD029 = false # ol-prefix - Ordered list item prefix +MD033 = false # no-inline-html Inline HTML +MD041 = false # first-line-heading/first-line-h1 +MD046 = false # indented-code-blocks +MD059 = false # descriptive-link-text + +# https://coverage.readthedocs.io/en/latest/config.html#run +[tool.coverage.run] +branch = true +patch = ["subprocess"] +concurrency = ["multiprocessing", "thread"] +source = ["src", "src/mcp-types/mcp_types", "tests"] +omit = [ + "src/mcp/client/__main__.py", + "src/mcp/server/__main__.py", + "src/mcp/os/posix/utilities.py", + "src/mcp/os/win32/utilities.py", +] + +# https://coverage.readthedocs.io/en/latest/config.html#report +[tool.coverage.report] +fail_under = 100 +skip_covered = true +show_missing = true +ignore_errors = true +precision = 2 +exclude_also = [ + "pragma: lax no cover", + "@overload", + "raise NotImplementedError", +] + +# https://coverage.readthedocs.io/en/latest/config.html#paths +[tool.coverage.paths] +source = [ + "src/", + "/home/runner/work/python-sdk/python-sdk/src/", + 'D:\a\python-sdk\python-sdk\src', +] + +[tool.inline-snapshot] +default-flags = ["disable"] +format-command = "ruff format --stdin-filename {filename}" diff --git a/schema/2025-11-25.json b/schema/2025-11-25.json new file mode 100644 index 0000000..4956c09 --- /dev/null +++ b/schema/2025-11-25.json @@ -0,0 +1,4057 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Annotations": { + "description": "Optional annotations for the client. The client can use annotations to inform how objects are used or displayed", + "properties": { + "audience": { + "description": "Describes who the intended audience of this object or data is.\n\nIt can include multiple entries to indicate content useful for multiple audiences (e.g., `[\"user\", \"assistant\"]`).", + "items": { + "$ref": "#/$defs/Role" + }, + "type": "array" + }, + "lastModified": { + "description": "The moment the resource was last modified, as an ISO 8601 formatted string.\n\nShould be an ISO 8601 formatted string (e.g., \"2025-01-12T15:00:58Z\").\n\nExamples: last activity timestamp in an open file, timestamp when the resource\nwas attached, etc.", + "type": "string" + }, + "priority": { + "description": "Describes how important this data is for operating the server.\n\nA value of 1 means \"most important,\" and indicates that the data is\neffectively required, while 0 means \"least important,\" and indicates that\nthe data is entirely optional.", + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "AudioContent": { + "description": "Audio provided to or from an LLM.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "data": { + "description": "The base64-encoded audio data.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the audio. Different providers may support different audio types.", + "type": "string" + }, + "type": { + "const": "audio", + "type": "string" + } + }, + "required": [ + "data", + "mimeType", + "type" + ], + "type": "object" + }, + "BaseMetadata": { + "description": "Base interface for metadata with name (identifier) and title (display name) properties.", + "properties": { + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "BlobResourceContents": { + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "blob": { + "description": "A base64-encoded string representing the binary data of the item.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "blob", + "uri" + ], + "type": "object" + }, + "BooleanSchema": { + "properties": { + "default": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "const": "boolean", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "CallToolRequest": { + "description": "Used by the client to invoke a tool provided by the server.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tools/call", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CallToolRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CallToolRequestParams": { + "description": "Parameters for a `tools/call` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "arguments": { + "additionalProperties": {}, + "description": "Arguments to use for the tool call.", + "type": "object" + }, + "name": { + "description": "The name of the tool.", + "type": "string" + }, + "task": { + "$ref": "#/$defs/TaskMetadata", + "description": "If specified, the caller is requesting task-augmented execution for this request.\nThe request will return a CreateTaskResult immediately, and the actual result can be\nretrieved later via tasks/result.\n\nTask augmentation is subject to capability negotiation - receivers MUST declare support\nfor task augmentation of specific request types in their capabilities." + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "CallToolResult": { + "description": "The server's response to a tool call.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "content": { + "description": "A list of content objects that represent the unstructured result of the tool call.", + "items": { + "$ref": "#/$defs/ContentBlock" + }, + "type": "array" + }, + "isError": { + "description": "Whether the tool call ended in an error.\n\nIf not set, this is assumed to be false (the call was successful).\n\nAny errors that originate from the tool SHOULD be reported inside the result\nobject, with `isError` set to true, _not_ as an MCP protocol-level error\nresponse. Otherwise, the LLM would not be able to see that an error occurred\nand self-correct.\n\nHowever, any errors in _finding_ the tool, an error indicating that the\nserver does not support tool calls, or any other exceptional conditions,\nshould be reported as an MCP error response.", + "type": "boolean" + }, + "structuredContent": { + "additionalProperties": {}, + "description": "An optional JSON object that represents the structured result of the tool call.", + "type": "object" + } + }, + "required": [ + "content" + ], + "type": "object" + }, + "CancelTaskRequest": { + "description": "A request to cancel a task.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tasks/cancel", + "type": "string" + }, + "params": { + "properties": { + "taskId": { + "description": "The task identifier to cancel.", + "type": "string" + } + }, + "required": [ + "taskId" + ], + "type": "object" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CancelTaskResult": { + "allOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "$ref": "#/$defs/Task" + } + ], + "description": "The response to a tasks/cancel request." + }, + "CancelledNotification": { + "description": "This notification can be sent by either side to indicate that it is cancelling a previously-issued request.\n\nThe request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished.\n\nThis notification indicates that the result will be unused, so any associated processing SHOULD cease.\n\nA client MUST NOT attempt to cancel its `initialize` request.\n\nFor task cancellation, use the `tasks/cancel` request instead of this notification.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/cancelled", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CancelledNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CancelledNotificationParams": { + "description": "Parameters for a `notifications/cancelled` notification.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "reason": { + "description": "An optional string describing the reason for the cancellation. This MAY be logged or presented to the user.", + "type": "string" + }, + "requestId": { + "$ref": "#/$defs/RequestId", + "description": "The ID of the request to cancel.\n\nThis MUST correspond to the ID of a request previously issued in the same direction.\nThis MUST be provided for cancelling non-task requests.\nThis MUST NOT be used for cancelling tasks (use the `tasks/cancel` request instead)." + } + }, + "type": "object" + }, + "ClientCapabilities": { + "description": "Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities.", + "properties": { + "elicitation": { + "description": "Present if the client supports elicitation from the server.", + "properties": { + "form": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "url": { + "additionalProperties": true, + "properties": {}, + "type": "object" + } + }, + "type": "object" + }, + "experimental": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "description": "Experimental, non-standard capabilities that the client supports.", + "type": "object" + }, + "roots": { + "description": "Present if the client supports listing roots.", + "properties": { + "listChanged": { + "description": "Whether the client supports notifications for changes to the roots list.", + "type": "boolean" + } + }, + "type": "object" + }, + "sampling": { + "description": "Present if the client supports sampling from an LLM.", + "properties": { + "context": { + "additionalProperties": true, + "description": "Whether the client supports context inclusion via includeContext parameter.\nIf not declared, servers SHOULD only use `includeContext: \"none\"` (or omit it).", + "properties": {}, + "type": "object" + }, + "tools": { + "additionalProperties": true, + "description": "Whether the client supports tool use via tools and toolChoice parameters.", + "properties": {}, + "type": "object" + } + }, + "type": "object" + }, + "tasks": { + "description": "Present if the client supports task-augmented requests.", + "properties": { + "cancel": { + "additionalProperties": true, + "description": "Whether this client supports tasks/cancel.", + "properties": {}, + "type": "object" + }, + "list": { + "additionalProperties": true, + "description": "Whether this client supports tasks/list.", + "properties": {}, + "type": "object" + }, + "requests": { + "description": "Specifies which request types can be augmented with tasks.", + "properties": { + "elicitation": { + "description": "Task support for elicitation-related requests.", + "properties": { + "create": { + "additionalProperties": true, + "description": "Whether the client supports task-augmented elicitation/create requests.", + "properties": {}, + "type": "object" + } + }, + "type": "object" + }, + "sampling": { + "description": "Task support for sampling-related requests.", + "properties": { + "createMessage": { + "additionalProperties": true, + "description": "Whether the client supports task-augmented sampling/createMessage requests.", + "properties": {}, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "ClientNotification": { + "anyOf": [ + { + "$ref": "#/$defs/CancelledNotification" + }, + { + "$ref": "#/$defs/InitializedNotification" + }, + { + "$ref": "#/$defs/ProgressNotification" + }, + { + "$ref": "#/$defs/TaskStatusNotification" + }, + { + "$ref": "#/$defs/RootsListChangedNotification" + } + ] + }, + "ClientRequest": { + "anyOf": [ + { + "$ref": "#/$defs/InitializeRequest" + }, + { + "$ref": "#/$defs/PingRequest" + }, + { + "$ref": "#/$defs/ListResourcesRequest" + }, + { + "$ref": "#/$defs/ListResourceTemplatesRequest" + }, + { + "$ref": "#/$defs/ReadResourceRequest" + }, + { + "$ref": "#/$defs/SubscribeRequest" + }, + { + "$ref": "#/$defs/UnsubscribeRequest" + }, + { + "$ref": "#/$defs/ListPromptsRequest" + }, + { + "$ref": "#/$defs/GetPromptRequest" + }, + { + "$ref": "#/$defs/ListToolsRequest" + }, + { + "$ref": "#/$defs/CallToolRequest" + }, + { + "$ref": "#/$defs/GetTaskRequest" + }, + { + "$ref": "#/$defs/GetTaskPayloadRequest" + }, + { + "$ref": "#/$defs/CancelTaskRequest" + }, + { + "$ref": "#/$defs/ListTasksRequest" + }, + { + "$ref": "#/$defs/SetLevelRequest" + }, + { + "$ref": "#/$defs/CompleteRequest" + } + ] + }, + "ClientResult": { + "anyOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "$ref": "#/$defs/GetTaskResult", + "description": "The response to a tasks/get request." + }, + { + "$ref": "#/$defs/GetTaskPayloadResult" + }, + { + "$ref": "#/$defs/CancelTaskResult", + "description": "The response to a tasks/cancel request." + }, + { + "$ref": "#/$defs/ListTasksResult" + }, + { + "$ref": "#/$defs/CreateMessageResult" + }, + { + "$ref": "#/$defs/ListRootsResult" + }, + { + "$ref": "#/$defs/ElicitResult" + } + ] + }, + "CompleteRequest": { + "description": "A request from the client to the server, to ask for completion options.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "completion/complete", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CompleteRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CompleteRequestParams": { + "description": "Parameters for a `completion/complete` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "argument": { + "description": "The argument's information", + "properties": { + "name": { + "description": "The name of the argument", + "type": "string" + }, + "value": { + "description": "The value of the argument to use for completion matching.", + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "type": "object" + }, + "context": { + "description": "Additional, optional context for completions", + "properties": { + "arguments": { + "additionalProperties": { + "type": "string" + }, + "description": "Previously-resolved variables in a URI template or prompt.", + "type": "object" + } + }, + "type": "object" + }, + "ref": { + "anyOf": [ + { + "$ref": "#/$defs/PromptReference" + }, + { + "$ref": "#/$defs/ResourceTemplateReference" + } + ] + } + }, + "required": [ + "argument", + "ref" + ], + "type": "object" + }, + "CompleteResult": { + "description": "The server's response to a completion/complete request", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "completion": { + "properties": { + "hasMore": { + "description": "Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown.", + "type": "boolean" + }, + "total": { + "description": "The total number of completion options available. This can exceed the number of values actually sent in the response.", + "type": "integer" + }, + "values": { + "description": "An array of completion values. Must not exceed 100 items.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "values" + ], + "type": "object" + } + }, + "required": [ + "completion" + ], + "type": "object" + }, + "ContentBlock": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ResourceLink" + }, + { + "$ref": "#/$defs/EmbeddedResource" + } + ] + }, + "CreateMessageRequest": { + "description": "A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "sampling/createMessage", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CreateMessageRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CreateMessageRequestParams": { + "description": "Parameters for a `sampling/createMessage` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "includeContext": { + "description": "A request to include context from one or more MCP servers (including the caller), to be attached to the prompt.\nThe client MAY ignore this request.\n\nDefault is \"none\". Values \"thisServer\" and \"allServers\" are soft-deprecated. Servers SHOULD only use these values if the client\ndeclares ClientCapabilities.sampling.context. These values may be removed in future spec releases.", + "enum": [ + "allServers", + "none", + "thisServer" + ], + "type": "string" + }, + "maxTokens": { + "description": "The requested maximum number of tokens to sample (to prevent runaway completions).\n\nThe client MAY choose to sample fewer tokens than the requested maximum.", + "type": "integer" + }, + "messages": { + "items": { + "$ref": "#/$defs/SamplingMessage" + }, + "type": "array" + }, + "metadata": { + "additionalProperties": true, + "description": "Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific.", + "properties": {}, + "type": "object" + }, + "modelPreferences": { + "$ref": "#/$defs/ModelPreferences", + "description": "The server's preferences for which model to select. The client MAY ignore these preferences." + }, + "stopSequences": { + "items": { + "type": "string" + }, + "type": "array" + }, + "systemPrompt": { + "description": "An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt.", + "type": "string" + }, + "task": { + "$ref": "#/$defs/TaskMetadata", + "description": "If specified, the caller is requesting task-augmented execution for this request.\nThe request will return a CreateTaskResult immediately, and the actual result can be\nretrieved later via tasks/result.\n\nTask augmentation is subject to capability negotiation - receivers MUST declare support\nfor task augmentation of specific request types in their capabilities." + }, + "temperature": { + "type": "number" + }, + "toolChoice": { + "$ref": "#/$defs/ToolChoice", + "description": "Controls how the model uses tools.\nThe client MUST return an error if this field is provided but ClientCapabilities.sampling.tools is not declared.\nDefault is `{ mode: \"auto\" }`." + }, + "tools": { + "description": "Tools that the model may use during generation.\nThe client MUST return an error if this field is provided but ClientCapabilities.sampling.tools is not declared.", + "items": { + "$ref": "#/$defs/Tool" + }, + "type": "array" + } + }, + "required": [ + "maxTokens", + "messages" + ], + "type": "object" + }, + "CreateMessageResult": { + "description": "The client's response to a sampling/createMessage request from the server.\nThe client should inform the user before returning the sampled message, to allow them\nto inspect the response (human in the loop) and decide whether to allow the server to see it.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "content": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + }, + { + "items": { + "$ref": "#/$defs/SamplingMessageContentBlock" + }, + "type": "array" + } + ] + }, + "model": { + "description": "The name of the model that generated the message.", + "type": "string" + }, + "role": { + "$ref": "#/$defs/Role" + }, + "stopReason": { + "description": "The reason why sampling stopped, if known.\n\nStandard values:\n- \"endTurn\": Natural end of the assistant's turn\n- \"stopSequence\": A stop sequence was encountered\n- \"maxTokens\": Maximum token limit was reached\n- \"toolUse\": The model wants to use one or more tools\n\nThis field is an open string to allow for provider-specific stop reasons.", + "type": "string" + } + }, + "required": [ + "content", + "model", + "role" + ], + "type": "object" + }, + "CreateTaskResult": { + "description": "A response to a task-augmented request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "task": { + "$ref": "#/$defs/Task" + } + }, + "required": [ + "task" + ], + "type": "object" + }, + "Cursor": { + "description": "An opaque token used to represent a cursor for pagination.", + "type": "string" + }, + "ElicitRequest": { + "description": "A request from the server to elicit additional information from the user via the client.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "elicitation/create", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ElicitRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ElicitRequestFormParams": { + "description": "The parameters for a request to elicit non-sensitive information from the user via a form in the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "message": { + "description": "The message to present to the user describing what information is being requested.", + "type": "string" + }, + "mode": { + "const": "form", + "description": "The elicitation mode.", + "type": "string" + }, + "requestedSchema": { + "description": "A restricted subset of JSON Schema.\nOnly top-level properties are allowed, without nesting.", + "properties": { + "$schema": { + "type": "string" + }, + "properties": { + "additionalProperties": { + "$ref": "#/$defs/PrimitiveSchemaDefinition" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "properties", + "type" + ], + "type": "object" + }, + "task": { + "$ref": "#/$defs/TaskMetadata", + "description": "If specified, the caller is requesting task-augmented execution for this request.\nThe request will return a CreateTaskResult immediately, and the actual result can be\nretrieved later via tasks/result.\n\nTask augmentation is subject to capability negotiation - receivers MUST declare support\nfor task augmentation of specific request types in their capabilities." + } + }, + "required": [ + "message", + "requestedSchema" + ], + "type": "object" + }, + "ElicitRequestParams": { + "anyOf": [ + { + "$ref": "#/$defs/ElicitRequestURLParams" + }, + { + "$ref": "#/$defs/ElicitRequestFormParams" + } + ], + "description": "The parameters for a request to elicit additional information from the user via the client." + }, + "ElicitRequestURLParams": { + "description": "The parameters for a request to elicit information from the user via a URL in the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "elicitationId": { + "description": "The ID of the elicitation, which must be unique within the context of the server.\nThe client MUST treat this ID as an opaque value.", + "type": "string" + }, + "message": { + "description": "The message to present to the user explaining why the interaction is needed.", + "type": "string" + }, + "mode": { + "const": "url", + "description": "The elicitation mode.", + "type": "string" + }, + "task": { + "$ref": "#/$defs/TaskMetadata", + "description": "If specified, the caller is requesting task-augmented execution for this request.\nThe request will return a CreateTaskResult immediately, and the actual result can be\nretrieved later via tasks/result.\n\nTask augmentation is subject to capability negotiation - receivers MUST declare support\nfor task augmentation of specific request types in their capabilities." + }, + "url": { + "description": "The URL that the user should navigate to.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "elicitationId", + "message", + "mode", + "url" + ], + "type": "object" + }, + "ElicitResult": { + "description": "The client's response to an elicitation request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "action": { + "description": "The user action in response to the elicitation.\n- \"accept\": User submitted the form/confirmed the action\n- \"decline\": User explicitly decline the action\n- \"cancel\": User dismissed without making an explicit choice", + "enum": [ + "accept", + "cancel", + "decline" + ], + "type": "string" + }, + "content": { + "additionalProperties": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "integer", + "boolean" + ] + } + ] + }, + "description": "The submitted form data, only present when action is \"accept\" and mode was \"form\".\nContains values matching the requested schema.\nOmitted for out-of-band mode responses.", + "type": "object" + } + }, + "required": [ + "action" + ], + "type": "object" + }, + "ElicitationCompleteNotification": { + "description": "An optional notification from the server to the client, informing it of a completion of a out-of-band elicitation request.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/elicitation/complete", + "type": "string" + }, + "params": { + "properties": { + "elicitationId": { + "description": "The ID of the elicitation that completed.", + "type": "string" + } + }, + "required": [ + "elicitationId" + ], + "type": "object" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "EmbeddedResource": { + "description": "The contents of a resource, embedded into a prompt or tool call result.\n\nIt is up to the client how best to render embedded resources for the benefit\nof the LLM and/or the user.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "resource": { + "anyOf": [ + { + "$ref": "#/$defs/TextResourceContents" + }, + { + "$ref": "#/$defs/BlobResourceContents" + } + ] + }, + "type": { + "const": "resource", + "type": "string" + } + }, + "required": [ + "resource", + "type" + ], + "type": "object" + }, + "EmptyResult": { + "$ref": "#/$defs/Result" + }, + "EnumSchema": { + "anyOf": [ + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/LegacyTitledEnumSchema" + } + ] + }, + "Error": { + "properties": { + "code": { + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "GetPromptRequest": { + "description": "Used by the client to get a prompt provided by the server.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "prompts/get", + "type": "string" + }, + "params": { + "$ref": "#/$defs/GetPromptRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "GetPromptRequestParams": { + "description": "Parameters for a `prompts/get` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "arguments": { + "additionalProperties": { + "type": "string" + }, + "description": "Arguments to use for templating the prompt.", + "type": "object" + }, + "name": { + "description": "The name of the prompt or prompt template.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "GetPromptResult": { + "description": "The server's response to a prompts/get request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "description": { + "description": "An optional description for the prompt.", + "type": "string" + }, + "messages": { + "items": { + "$ref": "#/$defs/PromptMessage" + }, + "type": "array" + } + }, + "required": [ + "messages" + ], + "type": "object" + }, + "GetTaskPayloadRequest": { + "description": "A request to retrieve the result of a completed task.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tasks/result", + "type": "string" + }, + "params": { + "properties": { + "taskId": { + "description": "The task identifier to retrieve results for.", + "type": "string" + } + }, + "required": [ + "taskId" + ], + "type": "object" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "GetTaskPayloadResult": { + "additionalProperties": {}, + "description": "The response to a tasks/result request.\nThe structure matches the result type of the original request.\nFor example, a tools/call task would return the CallToolResult structure.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + } + }, + "type": "object" + }, + "GetTaskRequest": { + "description": "A request to retrieve the state of a task.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tasks/get", + "type": "string" + }, + "params": { + "properties": { + "taskId": { + "description": "The task identifier to query.", + "type": "string" + } + }, + "required": [ + "taskId" + ], + "type": "object" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "GetTaskResult": { + "allOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "$ref": "#/$defs/Task" + } + ], + "description": "The response to a tasks/get request." + }, + "Icon": { + "description": "An optionally-sized icon that can be displayed in a user interface.", + "properties": { + "mimeType": { + "description": "Optional MIME type override if the source MIME type is missing or generic.\nFor example: `\"image/png\"`, `\"image/jpeg\"`, or `\"image/svg+xml\"`.", + "type": "string" + }, + "sizes": { + "description": "Optional array of strings that specify sizes at which the icon can be used.\nEach string should be in WxH format (e.g., `\"48x48\"`, `\"96x96\"`) or `\"any\"` for scalable formats like SVG.\n\nIf not provided, the client should assume that the icon can be used at any size.", + "items": { + "type": "string" + }, + "type": "array" + }, + "src": { + "description": "A standard URI pointing to an icon resource. May be an HTTP/HTTPS URL or a\n`data:` URI with Base64-encoded image data.\n\nConsumers SHOULD takes steps to ensure URLs serving icons are from the\nsame domain as the client/server or a trusted domain.\n\nConsumers SHOULD take appropriate precautions when consuming SVGs as they can contain\nexecutable JavaScript.", + "format": "uri", + "type": "string" + }, + "theme": { + "description": "Optional specifier for the theme this icon is designed for. `light` indicates\nthe icon is designed to be used with a light background, and `dark` indicates\nthe icon is designed to be used with a dark background.\n\nIf not provided, the client should assume the icon can be used with any theme.", + "enum": [ + "dark", + "light" + ], + "type": "string" + } + }, + "required": [ + "src" + ], + "type": "object" + }, + "Icons": { + "description": "Base interface to add `icons` property.", + "properties": { + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + } + }, + "type": "object" + }, + "ImageContent": { + "description": "An image provided to or from an LLM.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "data": { + "description": "The base64-encoded image data.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the image. Different providers may support different image types.", + "type": "string" + }, + "type": { + "const": "image", + "type": "string" + } + }, + "required": [ + "data", + "mimeType", + "type" + ], + "type": "object" + }, + "Implementation": { + "description": "Describes the MCP implementation.", + "properties": { + "description": { + "description": "An optional human-readable description of what this implementation does.\n\nThis can be used by clients or servers to provide context about their purpose\nand capabilities. For example, a server might describe the types of resources\nor tools it provides, while a client might describe its intended use case.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "version": { + "type": "string" + }, + "websiteUrl": { + "description": "An optional URL of the website for this implementation.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, + "InitializeRequest": { + "description": "This request is sent from the client to the server when it first connects, asking it to begin initialization.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "initialize", + "type": "string" + }, + "params": { + "$ref": "#/$defs/InitializeRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "InitializeRequestParams": { + "description": "Parameters for an `initialize` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "capabilities": { + "$ref": "#/$defs/ClientCapabilities" + }, + "clientInfo": { + "$ref": "#/$defs/Implementation" + }, + "protocolVersion": { + "description": "The latest version of the Model Context Protocol that the client supports. The client MAY decide to support older versions as well.", + "type": "string" + } + }, + "required": [ + "capabilities", + "clientInfo", + "protocolVersion" + ], + "type": "object" + }, + "InitializeResult": { + "description": "After receiving an initialize request from the client, the server sends this response.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "capabilities": { + "$ref": "#/$defs/ServerCapabilities" + }, + "instructions": { + "description": "Instructions describing how to use the server and its features.\n\nThis can be used by clients to improve the LLM's understanding of available tools, resources, etc. It can be thought of like a \"hint\" to the model. For example, this information MAY be added to the system prompt.", + "type": "string" + }, + "protocolVersion": { + "description": "The version of the Model Context Protocol that the server wants to use. This may not match the version that the client requested. If the client cannot support this version, it MUST disconnect.", + "type": "string" + }, + "serverInfo": { + "$ref": "#/$defs/Implementation" + } + }, + "required": [ + "capabilities", + "protocolVersion", + "serverInfo" + ], + "type": "object" + }, + "InitializedNotification": { + "description": "This notification is sent from the client to the server after initialization has finished.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/initialized", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCErrorResponse": { + "description": "A response to a request that indicates an error occurred.", + "properties": { + "error": { + "$ref": "#/$defs/Error" + }, + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "jsonrpc" + ], + "type": "object" + }, + "JSONRPCMessage": { + "anyOf": [ + { + "$ref": "#/$defs/JSONRPCRequest" + }, + { + "$ref": "#/$defs/JSONRPCNotification" + }, + { + "$ref": "#/$defs/JSONRPCResultResponse" + }, + { + "$ref": "#/$defs/JSONRPCErrorResponse" + } + ], + "description": "Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent." + }, + "JSONRPCNotification": { + "description": "A notification which does not expect a response.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCRequest": { + "description": "A request that expects a response.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCResponse": { + "anyOf": [ + { + "$ref": "#/$defs/JSONRPCResultResponse" + }, + { + "$ref": "#/$defs/JSONRPCErrorResponse" + } + ], + "description": "A response to a request, containing either the result or error." + }, + "JSONRPCResultResponse": { + "description": "A successful (non-error) response to a request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/Result" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "LegacyTitledEnumSchema": { + "description": "Use TitledSingleSelectEnumSchema instead.\nThis interface will be removed in a future version.", + "properties": { + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "enumNames": { + "description": "(Legacy) Display names for enum values.\nNon-standard according to JSON schema 2020-12.", + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "ListPromptsRequest": { + "description": "Sent from the client to request a list of prompts and prompt templates the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "prompts/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListPromptsResult": { + "description": "The server's response to a prompts/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "prompts": { + "items": { + "$ref": "#/$defs/Prompt" + }, + "type": "array" + } + }, + "required": [ + "prompts" + ], + "type": "object" + }, + "ListResourceTemplatesRequest": { + "description": "Sent from the client to request a list of resource templates the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/templates/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListResourceTemplatesResult": { + "description": "The server's response to a resources/templates/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resourceTemplates": { + "items": { + "$ref": "#/$defs/ResourceTemplate" + }, + "type": "array" + } + }, + "required": [ + "resourceTemplates" + ], + "type": "object" + }, + "ListResourcesRequest": { + "description": "Sent from the client to request a list of resources the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListResourcesResult": { + "description": "The server's response to a resources/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resources": { + "items": { + "$ref": "#/$defs/Resource" + }, + "type": "array" + } + }, + "required": [ + "resources" + ], + "type": "object" + }, + "ListRootsRequest": { + "description": "Sent from the server to request a list of root URIs from the client. Roots allow\nservers to ask for specific directories or files to operate on. A common example\nfor roots is providing a set of repositories or directories a server should operate\non.\n\nThis request is typically used when the server needs to understand the file system\nstructure or access specific locations that the client has permission to read from.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "roots/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/RequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListRootsResult": { + "description": "The client's response to a roots/list request from the server.\nThis result contains an array of Root objects, each representing a root directory\nor file that the server can operate on.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "roots": { + "items": { + "$ref": "#/$defs/Root" + }, + "type": "array" + } + }, + "required": [ + "roots" + ], + "type": "object" + }, + "ListTasksRequest": { + "description": "A request to retrieve a list of tasks.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tasks/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListTasksResult": { + "description": "The response to a tasks/list request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "tasks": { + "items": { + "$ref": "#/$defs/Task" + }, + "type": "array" + } + }, + "required": [ + "tasks" + ], + "type": "object" + }, + "ListToolsRequest": { + "description": "Sent from the client to request a list of tools the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tools/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListToolsResult": { + "description": "The server's response to a tools/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "tools": { + "items": { + "$ref": "#/$defs/Tool" + }, + "type": "array" + } + }, + "required": [ + "tools" + ], + "type": "object" + }, + "LoggingLevel": { + "description": "The severity of a log message.\n\nThese map to syslog message severities, as specified in RFC-5424:\nhttps://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1", + "enum": [ + "alert", + "critical", + "debug", + "emergency", + "error", + "info", + "notice", + "warning" + ], + "type": "string" + }, + "LoggingMessageNotification": { + "description": "JSONRPCNotification of a log message passed from server to client. If no logging/setLevel request has been sent from the client, the server MAY decide which messages to send automatically.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/message", + "type": "string" + }, + "params": { + "$ref": "#/$defs/LoggingMessageNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "LoggingMessageNotificationParams": { + "description": "Parameters for a `notifications/message` notification.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "data": { + "description": "The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here." + }, + "level": { + "$ref": "#/$defs/LoggingLevel", + "description": "The severity of this log message." + }, + "logger": { + "description": "An optional name of the logger issuing this message.", + "type": "string" + } + }, + "required": [ + "data", + "level" + ], + "type": "object" + }, + "ModelHint": { + "description": "Hints to use for model selection.\n\nKeys not declared here are currently left unspecified by the spec and are up\nto the client to interpret.", + "properties": { + "name": { + "description": "A hint for a model name.\n\nThe client SHOULD treat this as a substring of a model name; for example:\n - `claude-3-5-sonnet` should match `claude-3-5-sonnet-20241022`\n - `sonnet` should match `claude-3-5-sonnet-20241022`, `claude-3-sonnet-20240229`, etc.\n - `claude` should match any Claude model\n\nThe client MAY also map the string to a different provider's model name or a different model family, as long as it fills a similar niche; for example:\n - `gemini-1.5-flash` could match `claude-3-haiku-20240307`", + "type": "string" + } + }, + "type": "object" + }, + "ModelPreferences": { + "description": "The server's preferences for model selection, requested of the client during sampling.\n\nBecause LLMs can vary along multiple dimensions, choosing the \"best\" model is\nrarely straightforward. Different models excel in different areas—some are\nfaster but less capable, others are more capable but more expensive, and so\non. This interface allows servers to express their priorities across multiple\ndimensions to help clients make an appropriate selection for their use case.\n\nThese preferences are always advisory. The client MAY ignore them. It is also\nup to the client to decide how to interpret these preferences and how to\nbalance them against other considerations.", + "properties": { + "costPriority": { + "description": "How much to prioritize cost when selecting a model. A value of 0 means cost\nis not important, while a value of 1 means cost is the most important\nfactor.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "hints": { + "description": "Optional hints to use for model selection.\n\nIf multiple hints are specified, the client MUST evaluate them in order\n(such that the first match is taken).\n\nThe client SHOULD prioritize these hints over the numeric priorities, but\nMAY still use the priorities to select from ambiguous matches.", + "items": { + "$ref": "#/$defs/ModelHint" + }, + "type": "array" + }, + "intelligencePriority": { + "description": "How much to prioritize intelligence and capabilities when selecting a\nmodel. A value of 0 means intelligence is not important, while a value of 1\nmeans intelligence is the most important factor.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "speedPriority": { + "description": "How much to prioritize sampling speed (latency) when selecting a model. A\nvalue of 0 means speed is not important, while a value of 1 means speed is\nthe most important factor.", + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "MultiSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + } + ] + }, + "Notification": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "NotificationParams": { + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + } + }, + "type": "object" + }, + "NumberSchema": { + "properties": { + "default": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "maximum": { + "type": "integer" + }, + "minimum": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "enum": [ + "integer", + "number" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "PaginatedRequest": { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "PaginatedRequestParams": { + "description": "Common parameters for paginated requests.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "cursor": { + "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", + "type": "string" + } + }, + "type": "object" + }, + "PaginatedResult": { + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + } + }, + "type": "object" + }, + "PingRequest": { + "description": "A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "ping", + "type": "string" + }, + "params": { + "$ref": "#/$defs/RequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "PrimitiveSchemaDefinition": { + "anyOf": [ + { + "$ref": "#/$defs/StringSchema" + }, + { + "$ref": "#/$defs/NumberSchema" + }, + { + "$ref": "#/$defs/BooleanSchema" + }, + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/LegacyTitledEnumSchema" + } + ], + "description": "Restricted schema definitions that only allow primitive types\nwithout nested objects or arrays." + }, + "ProgressNotification": { + "description": "An out-of-band notification used to inform the receiver of a progress update for a long-running request.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/progress", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ProgressNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ProgressNotificationParams": { + "description": "Parameters for a `notifications/progress` notification.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "message": { + "description": "An optional message describing the current progress.", + "type": "string" + }, + "progress": { + "description": "The progress thus far. This should increase every time progress is made, even if the total is unknown.", + "type": "number" + }, + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "The progress token which was given in the initial request, used to associate this notification with the request that is proceeding." + }, + "total": { + "description": "Total number of items to process (or total progress required), if known.", + "type": "number" + } + }, + "required": [ + "progress", + "progressToken" + ], + "type": "object" + }, + "ProgressToken": { + "description": "A progress token, used to associate progress notifications with the original request.", + "type": [ + "string", + "integer" + ] + }, + "Prompt": { + "description": "A prompt or prompt template that the server offers.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "arguments": { + "description": "A list of arguments to use for templating the prompt.", + "items": { + "$ref": "#/$defs/PromptArgument" + }, + "type": "array" + }, + "description": { + "description": "An optional description of what this prompt provides", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "PromptArgument": { + "description": "Describes an argument that a prompt can accept.", + "properties": { + "description": { + "description": "A human-readable description of the argument.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "required": { + "description": "Whether this argument must be provided.", + "type": "boolean" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "PromptListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/prompts/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "PromptMessage": { + "description": "Describes a message returned as part of a prompt.\n\nThis is similar to `SamplingMessage`, but also supports the embedding of\nresources from the MCP server.", + "properties": { + "content": { + "$ref": "#/$defs/ContentBlock" + }, + "role": { + "$ref": "#/$defs/Role" + } + }, + "required": [ + "content", + "role" + ], + "type": "object" + }, + "PromptReference": { + "description": "Identifies a prompt.", + "properties": { + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "type": { + "const": "ref/prompt", + "type": "string" + } + }, + "required": [ + "name", + "type" + ], + "type": "object" + }, + "ReadResourceRequest": { + "description": "Sent from the client to the server, to read a specific resource URI.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/read", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ReadResourceRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ReadResourceRequestParams": { + "description": "Parameters for a `resources/read` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "ReadResourceResult": { + "description": "The server's response to a resources/read request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "contents": { + "items": { + "anyOf": [ + { + "$ref": "#/$defs/TextResourceContents" + }, + { + "$ref": "#/$defs/BlobResourceContents" + } + ] + }, + "type": "array" + } + }, + "required": [ + "contents" + ], + "type": "object" + }, + "RelatedTaskMetadata": { + "description": "Metadata for associating messages with a task.\nInclude this in the `_meta` field under the key `io.modelcontextprotocol/related-task`.", + "properties": { + "taskId": { + "description": "The task identifier this message is associated with.", + "type": "string" + } + }, + "required": [ + "taskId" + ], + "type": "object" + }, + "Request": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "RequestId": { + "description": "A uniquely identifying ID for a request in JSON-RPC.", + "type": [ + "string", + "integer" + ] + }, + "RequestParams": { + "description": "Common params for any request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + } + }, + "type": "object" + }, + "Resource": { + "description": "A known resource that the server is capable of reading.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this resource represents.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window usage.", + "type": "integer" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "uri" + ], + "type": "object" + }, + "ResourceContents": { + "description": "The contents of a specific resource or sub-resource.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "ResourceLink": { + "description": "A resource that the server is capable of reading, included in a prompt or tool call result.\n\nNote: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this resource represents.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window usage.", + "type": "integer" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "type": { + "const": "resource_link", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "type", + "uri" + ], + "type": "object" + }, + "ResourceListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/resources/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "ResourceRequestParams": { + "description": "Common parameters when working with resources.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "ResourceTemplate": { + "description": "A template description for resources available on the server.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this template is for.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "uriTemplate": { + "description": "A URI template (according to RFC 6570) that can be used to construct resource URIs.", + "format": "uri-template", + "type": "string" + } + }, + "required": [ + "name", + "uriTemplate" + ], + "type": "object" + }, + "ResourceTemplateReference": { + "description": "A reference to a resource or resource template definition.", + "properties": { + "type": { + "const": "ref/resource", + "type": "string" + }, + "uri": { + "description": "The URI or URI template of the resource.", + "format": "uri-template", + "type": "string" + } + }, + "required": [ + "type", + "uri" + ], + "type": "object" + }, + "ResourceUpdatedNotification": { + "description": "A notification from the server to the client, informing it that a resource has changed and may need to be read again. This should only be sent if the client previously sent a resources/subscribe request.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/resources/updated", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ResourceUpdatedNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ResourceUpdatedNotificationParams": { + "description": "Parameters for a `notifications/resources/updated` notification.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "uri": { + "description": "The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "Result": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + } + }, + "type": "object" + }, + "Role": { + "description": "The sender or recipient of messages and data in a conversation.", + "enum": [ + "assistant", + "user" + ], + "type": "string" + }, + "Root": { + "description": "Represents a root directory or file that the server can operate on.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "name": { + "description": "An optional name for the root. This can be used to provide a human-readable\nidentifier for the root, which may be useful for display purposes or for\nreferencing the root in other parts of the application.", + "type": "string" + }, + "uri": { + "description": "The URI identifying the root. This *must* start with file:// for now.\nThis restriction may be relaxed in future versions of the protocol to allow\nother URI schemes.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "RootsListChangedNotification": { + "description": "A notification from the client to the server, informing it that the list of roots has changed.\nThis notification should be sent whenever the client adds, removes, or modifies any root.\nThe server should then request an updated list of roots using the ListRootsRequest.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/roots/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "SamplingMessage": { + "description": "Describes a message issued to or received from an LLM API.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "content": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + }, + { + "items": { + "$ref": "#/$defs/SamplingMessageContentBlock" + }, + "type": "array" + } + ] + }, + "role": { + "$ref": "#/$defs/Role" + } + }, + "required": [ + "content", + "role" + ], + "type": "object" + }, + "SamplingMessageContentBlock": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + } + ] + }, + "ServerCapabilities": { + "description": "Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities.", + "properties": { + "completions": { + "additionalProperties": true, + "description": "Present if the server supports argument autocompletion suggestions.", + "properties": {}, + "type": "object" + }, + "experimental": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "description": "Experimental, non-standard capabilities that the server supports.", + "type": "object" + }, + "logging": { + "additionalProperties": true, + "description": "Present if the server supports sending log messages to the client.", + "properties": {}, + "type": "object" + }, + "prompts": { + "description": "Present if the server offers any prompt templates.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the prompt list.", + "type": "boolean" + } + }, + "type": "object" + }, + "resources": { + "description": "Present if the server offers any resources to read.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the resource list.", + "type": "boolean" + }, + "subscribe": { + "description": "Whether this server supports subscribing to resource updates.", + "type": "boolean" + } + }, + "type": "object" + }, + "tasks": { + "description": "Present if the server supports task-augmented requests.", + "properties": { + "cancel": { + "additionalProperties": true, + "description": "Whether this server supports tasks/cancel.", + "properties": {}, + "type": "object" + }, + "list": { + "additionalProperties": true, + "description": "Whether this server supports tasks/list.", + "properties": {}, + "type": "object" + }, + "requests": { + "description": "Specifies which request types can be augmented with tasks.", + "properties": { + "tools": { + "description": "Task support for tool-related requests.", + "properties": { + "call": { + "additionalProperties": true, + "description": "Whether the server supports task-augmented tools/call requests.", + "properties": {}, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "tools": { + "description": "Present if the server offers any tools to call.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the tool list.", + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "ServerNotification": { + "anyOf": [ + { + "$ref": "#/$defs/CancelledNotification" + }, + { + "$ref": "#/$defs/ProgressNotification" + }, + { + "$ref": "#/$defs/ResourceListChangedNotification" + }, + { + "$ref": "#/$defs/ResourceUpdatedNotification" + }, + { + "$ref": "#/$defs/PromptListChangedNotification" + }, + { + "$ref": "#/$defs/ToolListChangedNotification" + }, + { + "$ref": "#/$defs/TaskStatusNotification" + }, + { + "$ref": "#/$defs/LoggingMessageNotification" + }, + { + "$ref": "#/$defs/ElicitationCompleteNotification" + } + ] + }, + "ServerRequest": { + "anyOf": [ + { + "$ref": "#/$defs/PingRequest" + }, + { + "$ref": "#/$defs/GetTaskRequest" + }, + { + "$ref": "#/$defs/GetTaskPayloadRequest" + }, + { + "$ref": "#/$defs/CancelTaskRequest" + }, + { + "$ref": "#/$defs/ListTasksRequest" + }, + { + "$ref": "#/$defs/CreateMessageRequest" + }, + { + "$ref": "#/$defs/ListRootsRequest" + }, + { + "$ref": "#/$defs/ElicitRequest" + } + ] + }, + "ServerResult": { + "anyOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "$ref": "#/$defs/InitializeResult" + }, + { + "$ref": "#/$defs/ListResourcesResult" + }, + { + "$ref": "#/$defs/ListResourceTemplatesResult" + }, + { + "$ref": "#/$defs/ReadResourceResult" + }, + { + "$ref": "#/$defs/ListPromptsResult" + }, + { + "$ref": "#/$defs/GetPromptResult" + }, + { + "$ref": "#/$defs/ListToolsResult" + }, + { + "$ref": "#/$defs/CallToolResult" + }, + { + "$ref": "#/$defs/GetTaskResult", + "description": "The response to a tasks/get request." + }, + { + "$ref": "#/$defs/GetTaskPayloadResult" + }, + { + "$ref": "#/$defs/CancelTaskResult", + "description": "The response to a tasks/cancel request." + }, + { + "$ref": "#/$defs/ListTasksResult" + }, + { + "$ref": "#/$defs/CompleteResult" + } + ] + }, + "SetLevelRequest": { + "description": "A request from the client to the server, to enable or adjust logging.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "logging/setLevel", + "type": "string" + }, + "params": { + "$ref": "#/$defs/SetLevelRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "SetLevelRequestParams": { + "description": "Parameters for a `logging/setLevel` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "level": { + "$ref": "#/$defs/LoggingLevel", + "description": "The level of logging that the client wants to receive from the server. The server should send all logs at this level and higher (i.e., more severe) to the client as notifications/message." + } + }, + "required": [ + "level" + ], + "type": "object" + }, + "SingleSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + } + ] + }, + "StringSchema": { + "properties": { + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "format": { + "enum": [ + "date", + "date-time", + "email", + "uri" + ], + "type": "string" + }, + "maxLength": { + "type": "integer" + }, + "minLength": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "SubscribeRequest": { + "description": "Sent from the client to request resources/updated notifications from the server whenever a particular resource changes.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/subscribe", + "type": "string" + }, + "params": { + "$ref": "#/$defs/SubscribeRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "SubscribeRequestParams": { + "description": "Parameters for a `resources/subscribe` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "Task": { + "description": "Data associated with a task.", + "properties": { + "createdAt": { + "description": "ISO 8601 timestamp when the task was created.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO 8601 timestamp when the task was last updated.", + "type": "string" + }, + "pollInterval": { + "description": "Suggested polling interval in milliseconds.", + "type": "integer" + }, + "status": { + "$ref": "#/$defs/TaskStatus", + "description": "Current task state." + }, + "statusMessage": { + "description": "Optional human-readable message describing the current task state.\nThis can provide context for any status, including:\n- Reasons for \"cancelled\" status\n- Summaries for \"completed\" status\n- Diagnostic information for \"failed\" status (e.g., error details, what went wrong)", + "type": "string" + }, + "taskId": { + "description": "The task identifier.", + "type": "string" + }, + "ttl": { + "description": "Actual retention duration from creation in milliseconds, null for unlimited.", + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "createdAt", + "lastUpdatedAt", + "status", + "taskId", + "ttl" + ], + "type": "object" + }, + "TaskAugmentedRequestParams": { + "description": "Common params for any task-augmented request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "task": { + "$ref": "#/$defs/TaskMetadata", + "description": "If specified, the caller is requesting task-augmented execution for this request.\nThe request will return a CreateTaskResult immediately, and the actual result can be\nretrieved later via tasks/result.\n\nTask augmentation is subject to capability negotiation - receivers MUST declare support\nfor task augmentation of specific request types in their capabilities." + } + }, + "type": "object" + }, + "TaskMetadata": { + "description": "Metadata for augmenting a request with task execution.\nInclude this in the `task` field of the request parameters.", + "properties": { + "ttl": { + "description": "Requested duration in milliseconds to retain task from creation.", + "type": "integer" + } + }, + "type": "object" + }, + "TaskStatus": { + "description": "The status of a task.", + "enum": [ + "cancelled", + "completed", + "failed", + "input_required", + "working" + ], + "type": "string" + }, + "TaskStatusNotification": { + "description": "An optional notification from the receiver to the requestor, informing them that a task's status has changed. Receivers are not required to send these notifications.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/tasks/status", + "type": "string" + }, + "params": { + "$ref": "#/$defs/TaskStatusNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "TaskStatusNotificationParams": { + "allOf": [ + { + "$ref": "#/$defs/NotificationParams" + }, + { + "$ref": "#/$defs/Task" + } + ], + "description": "Parameters for a `notifications/tasks/status` notification." + }, + "TextContent": { + "description": "Text provided to or from an LLM.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "text": { + "description": "The text content of the message.", + "type": "string" + }, + "type": { + "const": "text", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "type": "object" + }, + "TextResourceContents": { + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "text": { + "description": "The text of the item. This must only be set if the item can actually be represented as text (not binary data).", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "text", + "uri" + ], + "type": "object" + }, + "TitledMultiSelectEnumSchema": { + "description": "Schema for multiple-selection enumeration with display titles for each option.", + "properties": { + "default": { + "description": "Optional default value.", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "items": { + "description": "Schema for array items with enum options and display labels.", + "properties": { + "anyOf": { + "description": "Array of enum options with values and display labels.", + "items": { + "properties": { + "const": { + "description": "The constant enum value.", + "type": "string" + }, + "title": { + "description": "Display title for this option.", + "type": "string" + } + }, + "required": [ + "const", + "title" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "anyOf" + ], + "type": "object" + }, + "maxItems": { + "description": "Maximum number of items to select.", + "type": "integer" + }, + "minItems": { + "description": "Minimum number of items to select.", + "type": "integer" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "array", + "type": "string" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "TitledSingleSelectEnumSchema": { + "description": "Schema for single-selection enumeration with display titles for each option.", + "properties": { + "default": { + "description": "Optional default value.", + "type": "string" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "oneOf": { + "description": "Array of enum options with values and display labels.", + "items": { + "properties": { + "const": { + "description": "The enum value.", + "type": "string" + }, + "title": { + "description": "Display label for this option.", + "type": "string" + } + }, + "required": [ + "const", + "title" + ], + "type": "object" + }, + "type": "array" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "oneOf", + "type" + ], + "type": "object" + }, + "Tool": { + "description": "Definition for a tool the client can call.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/ToolAnnotations", + "description": "Optional additional tool information.\n\nDisplay name precedence order is: title, annotations.title, then name." + }, + "description": { + "description": "A human-readable description of the tool.\n\nThis can be used by clients to improve the LLM's understanding of available tools. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "execution": { + "$ref": "#/$defs/ToolExecution", + "description": "Execution-related properties for this tool." + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "inputSchema": { + "description": "A JSON Schema object defining the expected parameters for the tool.", + "properties": { + "$schema": { + "type": "string" + }, + "properties": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "outputSchema": { + "description": "An optional JSON Schema object defining the structure of the tool's output returned in\nthe structuredContent field of a CallToolResult.\n\nDefaults to JSON Schema 2020-12 when no explicit $schema is provided.\nCurrently restricted to type: \"object\" at the root level.", + "properties": { + "$schema": { + "type": "string" + }, + "properties": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "inputSchema", + "name" + ], + "type": "object" + }, + "ToolAnnotations": { + "description": "Additional properties describing a Tool to clients.\n\nNOTE: all properties in ToolAnnotations are **hints**.\nThey are not guaranteed to provide a faithful description of\ntool behavior (including descriptive properties like `title`).\n\nClients should never make tool use decisions based on ToolAnnotations\nreceived from untrusted servers.", + "properties": { + "destructiveHint": { + "description": "If true, the tool may perform destructive updates to its environment.\nIf false, the tool performs only additive updates.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: true", + "type": "boolean" + }, + "idempotentHint": { + "description": "If true, calling the tool repeatedly with the same arguments\nwill have no additional effect on its environment.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: false", + "type": "boolean" + }, + "openWorldHint": { + "description": "If true, this tool may interact with an \"open world\" of external\nentities. If false, the tool's domain of interaction is closed.\nFor example, the world of a web search tool is open, whereas that\nof a memory tool is not.\n\nDefault: true", + "type": "boolean" + }, + "readOnlyHint": { + "description": "If true, the tool does not modify its environment.\n\nDefault: false", + "type": "boolean" + }, + "title": { + "description": "A human-readable title for the tool.", + "type": "string" + } + }, + "type": "object" + }, + "ToolChoice": { + "description": "Controls tool selection behavior for sampling requests.", + "properties": { + "mode": { + "description": "Controls the tool use ability of the model:\n- \"auto\": Model decides whether to use tools (default)\n- \"required\": Model MUST use at least one tool before completing\n- \"none\": Model MUST NOT use any tools", + "enum": [ + "auto", + "none", + "required" + ], + "type": "string" + } + }, + "type": "object" + }, + "ToolExecution": { + "description": "Execution-related properties for a tool.", + "properties": { + "taskSupport": { + "description": "Indicates whether this tool supports task-augmented execution.\nThis allows clients to handle long-running operations through polling\nthe task system.\n\n- \"forbidden\": Tool does not support task-augmented execution (default when absent)\n- \"optional\": Tool may support task-augmented execution\n- \"required\": Tool requires task-augmented execution\n\nDefault: \"forbidden\"", + "enum": [ + "forbidden", + "optional", + "required" + ], + "type": "string" + } + }, + "type": "object" + }, + "ToolListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of tools it offers has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/tools/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "ToolResultContent": { + "description": "The result of a tool use, provided by the user back to the assistant.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "Optional metadata about the tool result. Clients SHOULD preserve this field when\nincluding tool results in subsequent sampling requests to enable caching optimizations.\n\nSee [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "content": { + "description": "The unstructured result content of the tool use.\n\nThis has the same format as CallToolResult.content and can include text, images,\naudio, resource links, and embedded resources.", + "items": { + "$ref": "#/$defs/ContentBlock" + }, + "type": "array" + }, + "isError": { + "description": "Whether the tool use resulted in an error.\n\nIf true, the content typically describes the error that occurred.\nDefault: false", + "type": "boolean" + }, + "structuredContent": { + "additionalProperties": {}, + "description": "An optional structured result object.\n\nIf the tool defined an outputSchema, this SHOULD conform to that schema.", + "type": "object" + }, + "toolUseId": { + "description": "The ID of the tool use this result corresponds to.\n\nThis MUST match the ID from a previous ToolUseContent.", + "type": "string" + }, + "type": { + "const": "tool_result", + "type": "string" + } + }, + "required": [ + "content", + "toolUseId", + "type" + ], + "type": "object" + }, + "ToolUseContent": { + "description": "A request from the assistant to call a tool.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "Optional metadata about the tool use. Clients SHOULD preserve this field when\nincluding tool uses in subsequent sampling requests to enable caching optimizations.\n\nSee [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "id": { + "description": "A unique identifier for this tool use.\n\nThis ID is used to match tool results to their corresponding tool uses.", + "type": "string" + }, + "input": { + "additionalProperties": {}, + "description": "The arguments to pass to the tool, conforming to the tool's input schema.", + "type": "object" + }, + "name": { + "description": "The name of the tool to call.", + "type": "string" + }, + "type": { + "const": "tool_use", + "type": "string" + } + }, + "required": [ + "id", + "input", + "name", + "type" + ], + "type": "object" + }, + "URLElicitationRequiredError": { + "description": "An error response that indicates that the server requires the client to provide additional information via an elicitation request.", + "properties": { + "error": { + "allOf": [ + { + "$ref": "#/$defs/Error" + }, + { + "properties": { + "code": { + "const": -32042, + "type": "integer" + }, + "data": { + "additionalProperties": {}, + "properties": { + "elicitations": { + "items": { + "$ref": "#/$defs/ElicitRequestURLParams" + }, + "type": "array" + } + }, + "required": [ + "elicitations" + ], + "type": "object" + } + }, + "required": [ + "code", + "data" + ], + "type": "object" + } + ] + }, + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "jsonrpc" + ], + "type": "object" + }, + "UnsubscribeRequest": { + "description": "Sent from the client to request cancellation of resources/updated notifications from the server. This should follow a previous resources/subscribe request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/unsubscribe", + "type": "string" + }, + "params": { + "$ref": "#/$defs/UnsubscribeRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "UnsubscribeRequestParams": { + "description": "Parameters for a `resources/unsubscribe` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "UntitledMultiSelectEnumSchema": { + "description": "Schema for multiple-selection enumeration without display titles for options.", + "properties": { + "default": { + "description": "Optional default value.", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "items": { + "description": "Schema for the array items.", + "properties": { + "enum": { + "description": "Array of enum values to choose from.", + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "maxItems": { + "description": "Maximum number of items to select.", + "type": "integer" + }, + "minItems": { + "description": "Minimum number of items to select.", + "type": "integer" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "array", + "type": "string" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "UntitledSingleSelectEnumSchema": { + "description": "Schema for single-selection enumeration without display titles for options.", + "properties": { + "default": { + "description": "Optional default value.", + "type": "string" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "enum": { + "description": "Array of enum values to choose from.", + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + } + } +} diff --git a/schema/2026-07-28.json b/schema/2026-07-28.json new file mode 100644 index 0000000..87116a4 --- /dev/null +++ b/schema/2026-07-28.json @@ -0,0 +1,3933 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Annotations": { + "description": "Optional annotations for the client. The client can use annotations to inform how objects are used or displayed", + "properties": { + "audience": { + "description": "Describes who the intended audience of this object or data is.\n\nIt can include multiple entries to indicate content useful for multiple audiences (e.g., `[\"user\", \"assistant\"]`).", + "items": { + "$ref": "#/$defs/Role" + }, + "type": "array" + }, + "lastModified": { + "description": "The moment the resource was last modified, as an ISO 8601 formatted string.\n\nShould be an ISO 8601 formatted string (e.g., \"2025-01-12T15:00:58Z\").\n\nExamples: last activity timestamp in an open file, timestamp when the resource\nwas attached, etc.", + "type": "string" + }, + "priority": { + "description": "Describes how important this data is for operating the server.\n\nA value of 1 means \"most important,\" and indicates that the data is\neffectively required, while 0 means \"least important,\" and indicates that\nthe data is entirely optional.", + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "AudioContent": { + "description": "Audio provided to or from an LLM.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "data": { + "description": "The base64-encoded audio data.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the audio. Different providers may support different audio types.", + "type": "string" + }, + "type": { + "const": "audio", + "type": "string" + } + }, + "required": [ + "data", + "mimeType", + "type" + ], + "type": "object" + }, + "BaseMetadata": { + "description": "Base interface for metadata with name (identifier) and title (display name) properties.", + "properties": { + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "BlobResourceContents": { + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "blob": { + "description": "A base64-encoded string representing the binary data of the item.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "blob", + "uri" + ], + "type": "object" + }, + "BooleanSchema": { + "properties": { + "default": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "const": "boolean", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "CacheableResult": { + "description": "A result that supports a time-to-live (TTL) hint for client-side caching.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "resultType", + "ttlMs" + ], + "type": "object" + }, + "CallToolRequest": { + "description": "Used by the client to invoke a tool provided by the server.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tools/call", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CallToolRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CallToolRequestParams": { + "description": "Parameters for a `tools/call` request.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "arguments": { + "additionalProperties": {}, + "description": "Arguments to use for the tool call.", + "type": "object" + }, + "inputResponses": { + "$ref": "#/$defs/InputResponses" + }, + "name": { + "description": "The name of the tool.", + "type": "string" + }, + "requestState": { + "type": "string" + } + }, + "required": [ + "_meta", + "name" + ], + "type": "object" + }, + "CallToolResult": { + "description": "The result returned by the server for a {@link CallToolRequesttools/call} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "content": { + "description": "A list of content objects that represent the unstructured result of the tool call.", + "items": { + "$ref": "#/$defs/ContentBlock" + }, + "type": "array" + }, + "isError": { + "description": "Whether the tool call ended in an error.\n\nIf not set, this is assumed to be false (the call was successful).\n\nAny errors that originate from the tool SHOULD be reported inside the result\nobject, with `isError` set to true, _not_ as an MCP protocol-level error\nresponse. Otherwise, the LLM would not be able to see that an error occurred\nand self-correct.\n\nHowever, any errors in _finding_ the tool, an error indicating that the\nserver does not support tool calls, or any other exceptional conditions,\nshould be reported as an MCP error response.", + "type": "boolean" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "structuredContent": { + "description": "An optional JSON value that represents the structured result of the tool call.\n\nThis can be any JSON value (object, array, string, number, boolean, or null)\nthat conforms to the tool's outputSchema if one is defined." + } + }, + "required": [ + "content", + "resultType" + ], + "type": "object" + }, + "CallToolResultResponse": { + "description": "A successful response from the server for a {@link CallToolRequesttools/call} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "anyOf": [ + { + "$ref": "#/$defs/InputRequiredResult" + }, + { + "$ref": "#/$defs/CallToolResult" + } + ] + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "CancelledNotification": { + "description": "This notification is sent by the client to indicate that it is cancelling a request it previously issued.\n\nOn stdio, the server also sends this notification, solely to terminate a {@link SubscriptionsListenRequestsubscriptions/listen} stream: it references the ID of the `subscriptions/listen` request that opened the stream. Servers MUST NOT use this notification to cancel any other request.\n\nThe request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished.\n\nThis notification indicates that the result will be unused, so any associated processing SHOULD cease.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/cancelled", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CancelledNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CancelledNotificationParams": { + "description": "Parameters for a `notifications/cancelled` notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/NotificationMetaObject" + }, + "reason": { + "description": "An optional string describing the reason for the cancellation. This MAY be logged or presented to the user.", + "type": "string" + }, + "requestId": { + "$ref": "#/$defs/RequestId", + "description": "The ID of the request to cancel.\n\nThis MUST correspond to the ID of a request the client previously issued." + } + }, + "required": [ + "requestId" + ], + "type": "object" + }, + "ClientCapabilities": { + "description": "Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities.", + "properties": { + "elicitation": { + "description": "Present if the client supports elicitation from the server.", + "properties": { + "form": { + "$ref": "#/$defs/JSONObject" + }, + "url": { + "$ref": "#/$defs/JSONObject" + } + }, + "type": "object" + }, + "experimental": { + "additionalProperties": { + "$ref": "#/$defs/JSONObject" + }, + "description": "Experimental, non-standard capabilities that the client supports.", + "type": "object" + }, + "extensions": { + "additionalProperties": { + "$ref": "#/$defs/JSONObject" + }, + "description": "Optional MCP extensions that the client supports. Keys are extension identifiers\n(e.g., \"io.modelcontextprotocol/oauth-client-credentials\"), and values are\nper-extension settings objects. An empty object indicates support with no settings.\n\nKeys MUST follow the {@link MetaObject`_meta` key naming rules}, with a\nmandatory prefix.", + "type": "object" + }, + "roots": { + "description": "Present if the client supports listing roots.", + "properties": {}, + "type": "object" + }, + "sampling": { + "description": "Present if the client supports sampling from an LLM.", + "properties": { + "context": { + "$ref": "#/$defs/JSONObject", + "description": "Whether the client supports context inclusion via `includeContext` parameter.\nIf not declared, servers SHOULD only use `includeContext: \"none\"` (or omit it)." + }, + "tools": { + "$ref": "#/$defs/JSONObject", + "description": "Whether the client supports tool use via `tools` and `toolChoice` parameters." + } + }, + "type": "object" + } + }, + "type": "object" + }, + "ClientNotification": { + "description": "This notification is sent by the client to indicate that it is cancelling a request it previously issued.\n\nOn stdio, the server also sends this notification, solely to terminate a {@link SubscriptionsListenRequestsubscriptions/listen} stream: it references the ID of the `subscriptions/listen` request that opened the stream. Servers MUST NOT use this notification to cancel any other request.\n\nThe request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished.\n\nThis notification indicates that the result will be unused, so any associated processing SHOULD cease.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/cancelled", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CancelledNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ClientRequest": { + "anyOf": [ + { + "$ref": "#/$defs/DiscoverRequest" + }, + { + "$ref": "#/$defs/ListResourcesRequest" + }, + { + "$ref": "#/$defs/ListResourceTemplatesRequest" + }, + { + "$ref": "#/$defs/ReadResourceRequest" + }, + { + "$ref": "#/$defs/SubscriptionsListenRequest" + }, + { + "$ref": "#/$defs/ListPromptsRequest" + }, + { + "$ref": "#/$defs/GetPromptRequest" + }, + { + "$ref": "#/$defs/ListToolsRequest" + }, + { + "$ref": "#/$defs/CallToolRequest" + }, + { + "$ref": "#/$defs/CompleteRequest" + } + ] + }, + "ClientResult": { + "$ref": "#/$defs/Result", + "description": "Common result fields." + }, + "CompleteRequest": { + "description": "A request from the client to the server, to ask for completion options.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "completion/complete", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CompleteRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CompleteRequestParams": { + "description": "Parameters for a `completion/complete` request.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "argument": { + "description": "The argument's information", + "properties": { + "name": { + "description": "The name of the argument", + "type": "string" + }, + "value": { + "description": "The value of the argument to use for completion matching.", + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "type": "object" + }, + "context": { + "description": "Additional, optional context for completions", + "properties": { + "arguments": { + "additionalProperties": { + "type": "string" + }, + "description": "Previously-resolved variables in a URI template or prompt.", + "type": "object" + } + }, + "type": "object" + }, + "ref": { + "anyOf": [ + { + "$ref": "#/$defs/PromptReference" + }, + { + "$ref": "#/$defs/ResourceTemplateReference" + } + ] + } + }, + "required": [ + "_meta", + "argument", + "ref" + ], + "type": "object" + }, + "CompleteResult": { + "description": "The result returned by the server for a {@link CompleteRequestcompletion/complete} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "completion": { + "properties": { + "hasMore": { + "description": "Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown.", + "type": "boolean" + }, + "total": { + "description": "The total number of completion options available. This can exceed the number of values actually sent in the response.", + "type": "integer" + }, + "values": { + "description": "An array of completion values. Must not exceed 100 items.", + "items": { + "type": "string" + }, + "maxItems": 100, + "type": "array" + } + }, + "required": [ + "values" + ], + "type": "object" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + } + }, + "required": [ + "completion", + "resultType" + ], + "type": "object" + }, + "CompleteResultResponse": { + "description": "A successful response from the server for a {@link CompleteRequestcompletion/complete} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/CompleteResult" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "ContentBlock": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ResourceLink" + }, + { + "$ref": "#/$defs/EmbeddedResource" + } + ] + }, + "CreateMessageRequest": { + "description": "A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it.", + "properties": { + "method": { + "const": "sampling/createMessage", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CreateMessageRequestParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "CreateMessageRequestParams": { + "description": "Parameters for a `sampling/createMessage` request.", + "properties": { + "includeContext": { + "description": "A request to include context from one or more MCP servers (including the caller), to be attached to the prompt.\nThe client MAY ignore this request.\n\nDefault is `\"none\"`. The values `\"thisServer\"` and `\"allServers\"` are deprecated (SEP-2596): servers SHOULD\nomit this field or use `\"none\"`, and SHOULD only use the deprecated values if the client declares\n{@link ClientCapabilities.sampling.context}.", + "enum": [ + "allServers", + "none", + "thisServer" + ], + "type": "string" + }, + "maxTokens": { + "description": "The requested maximum number of tokens to sample (to prevent runaway completions).\n\nThe client MAY choose to sample fewer tokens than the requested maximum.", + "type": "integer" + }, + "messages": { + "items": { + "$ref": "#/$defs/SamplingMessage" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/$defs/JSONObject", + "description": "Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific." + }, + "modelPreferences": { + "$ref": "#/$defs/ModelPreferences", + "description": "The server's preferences for which model to select. The client MAY ignore these preferences." + }, + "stopSequences": { + "items": { + "type": "string" + }, + "type": "array" + }, + "systemPrompt": { + "description": "An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt.", + "type": "string" + }, + "temperature": { + "type": "number" + }, + "toolChoice": { + "$ref": "#/$defs/ToolChoice", + "description": "Controls how the model uses tools.\nThe client MUST return an error if this field is provided but {@link ClientCapabilities.sampling.tools} is not declared.\nDefault is `{ mode: \"auto\" }`." + }, + "tools": { + "description": "Tools that the model may use during generation.\nThe client MUST return an error if this field is provided but {@link ClientCapabilities.sampling.tools} is not declared.", + "items": { + "$ref": "#/$defs/Tool" + }, + "type": "array" + } + }, + "required": [ + "maxTokens", + "messages" + ], + "type": "object" + }, + "CreateMessageResult": { + "description": "The result returned by the client for a {@link CreateMessageRequestsampling/createMessage} request.\nThe client should inform the user before returning the sampled message, to allow them\nto inspect the response (human in the loop) and decide whether to allow the server to see it.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "content": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + }, + { + "items": { + "$ref": "#/$defs/SamplingMessageContentBlock" + }, + "type": "array" + } + ] + }, + "model": { + "description": "The name of the model that generated the message.", + "type": "string" + }, + "role": { + "$ref": "#/$defs/Role" + }, + "stopReason": { + "description": "The reason why sampling stopped, if known.\n\nStandard values:\n- `\"endTurn\"`: Natural end of the assistant's turn\n- `\"stopSequence\"`: A stop sequence was encountered\n- `\"maxTokens\"`: Maximum token limit was reached\n- `\"toolUse\"`: The model wants to use one or more tools\n\nThis field is an open string to allow for provider-specific stop reasons.", + "type": "string" + } + }, + "required": [ + "content", + "model", + "role" + ], + "type": "object" + }, + "Cursor": { + "description": "An opaque token used to represent a cursor for pagination.", + "type": "string" + }, + "DiscoverRequest": { + "description": "A request from the client asking the server to advertise its supported\nprotocol versions, capabilities, and other metadata. Servers **MUST**\nimplement `server/discover`. Clients **MAY** call it but are not required\nto — version negotiation can also happen inline via per-request `_meta`.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "server/discover", + "type": "string" + }, + "params": { + "$ref": "#/$defs/RequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "DiscoverResult": { + "description": "The result returned by the server for a {@link DiscoverRequestserver/discover} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "capabilities": { + "$ref": "#/$defs/ServerCapabilities", + "description": "The capabilities of the server." + }, + "instructions": { + "description": "Natural-language guidance describing the server and its features.\n\nThis can be used by clients to improve an LLM's understanding of\navailable tools (e.g., by including it in a system prompt). It should\nfocus on information that helps the model use the server effectively\nand should not duplicate information already in tool descriptions.", + "type": "string" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "serverInfo": { + "$ref": "#/$defs/Implementation", + "description": "Information about the server software implementation." + }, + "supportedVersions": { + "description": "MCP Protocol Versions this server supports. The client should choose a\nversion from this list for use in subsequent requests.", + "items": { + "type": "string" + }, + "type": "array" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "capabilities", + "resultType", + "serverInfo", + "supportedVersions", + "ttlMs" + ], + "type": "object" + }, + "DiscoverResultResponse": { + "description": "A successful response from the server for a {@link DiscoverRequestserver/discover} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/DiscoverResult" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "ElicitRequest": { + "description": "A request from the server to elicit additional information from the user via the client.", + "properties": { + "method": { + "const": "elicitation/create", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ElicitRequestParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "ElicitRequestFormParams": { + "description": "The parameters for a request to elicit non-sensitive information from the user via a form in the client.", + "properties": { + "message": { + "description": "The message to present to the user describing what information is being requested.", + "type": "string" + }, + "mode": { + "const": "form", + "description": "The elicitation mode.", + "type": "string" + }, + "requestedSchema": { + "description": "A restricted subset of JSON Schema.\nOnly top-level properties are allowed, without nesting.", + "properties": { + "$schema": { + "type": "string" + }, + "properties": { + "additionalProperties": { + "$ref": "#/$defs/PrimitiveSchemaDefinition" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "properties", + "type" + ], + "type": "object" + } + }, + "required": [ + "message", + "requestedSchema" + ], + "type": "object" + }, + "ElicitRequestParams": { + "anyOf": [ + { + "$ref": "#/$defs/ElicitRequestFormParams" + }, + { + "$ref": "#/$defs/ElicitRequestURLParams" + } + ], + "description": "The parameters for a request to elicit additional information from the user via the client." + }, + "ElicitRequestURLParams": { + "description": "The parameters for a request to elicit information from the user via a URL in the client.", + "properties": { + "message": { + "description": "The message to present to the user explaining why the interaction is needed.", + "type": "string" + }, + "mode": { + "const": "url", + "description": "The elicitation mode.", + "type": "string" + }, + "url": { + "description": "The URL that the user should navigate to.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "message", + "mode", + "url" + ], + "type": "object" + }, + "ElicitResult": { + "description": "The result returned by the client for an {@link ElicitRequestelicitation/create} request.", + "properties": { + "action": { + "description": "The user action in response to the elicitation.\n- `\"accept\"`: User submitted the form/confirmed the action\n- `\"decline\"`: User explicitly declined the action\n- `\"cancel\"`: User dismissed without making an explicit choice", + "enum": [ + "accept", + "cancel", + "decline" + ], + "type": "string" + }, + "content": { + "additionalProperties": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "integer", + "boolean" + ] + } + ] + }, + "description": "The submitted form data, only present when action is `\"accept\"` and mode was `\"form\"`.\nContains values matching the requested schema.\nOmitted for out-of-band mode responses.", + "type": "object" + } + }, + "required": [ + "action" + ], + "type": "object" + }, + "EmbeddedResource": { + "description": "The contents of a resource, embedded into a prompt or tool call result.\n\nIt is up to the client how best to render embedded resources for the benefit\nof the LLM and/or the user.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "resource": { + "anyOf": [ + { + "$ref": "#/$defs/TextResourceContents" + }, + { + "$ref": "#/$defs/BlobResourceContents" + } + ] + }, + "type": { + "const": "resource", + "type": "string" + } + }, + "required": [ + "resource", + "type" + ], + "type": "object" + }, + "EmptyResult": { + "$ref": "#/$defs/Result", + "description": "Common result fields." + }, + "EnumSchema": { + "anyOf": [ + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/LegacyTitledEnumSchema" + } + ] + }, + "Error": { + "properties": { + "code": { + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "GetPromptRequest": { + "description": "Used by the client to get a prompt provided by the server.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "prompts/get", + "type": "string" + }, + "params": { + "$ref": "#/$defs/GetPromptRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "GetPromptRequestParams": { + "description": "Parameters for a `prompts/get` request.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "arguments": { + "additionalProperties": { + "type": "string" + }, + "description": "Arguments to use for templating the prompt.", + "type": "object" + }, + "inputResponses": { + "$ref": "#/$defs/InputResponses" + }, + "name": { + "description": "The name of the prompt or prompt template.", + "type": "string" + }, + "requestState": { + "type": "string" + } + }, + "required": [ + "_meta", + "name" + ], + "type": "object" + }, + "GetPromptResult": { + "description": "The result returned by the server for a {@link GetPromptRequestprompts/get} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "description": { + "description": "An optional description for the prompt.", + "type": "string" + }, + "messages": { + "items": { + "$ref": "#/$defs/PromptMessage" + }, + "type": "array" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + } + }, + "required": [ + "messages", + "resultType" + ], + "type": "object" + }, + "GetPromptResultResponse": { + "description": "A successful response from the server for a {@link GetPromptRequestprompts/get} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "anyOf": [ + { + "$ref": "#/$defs/InputRequiredResult" + }, + { + "$ref": "#/$defs/GetPromptResult" + } + ] + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "HeaderMismatchError": { + "description": "Returned when a server rejects a request because the values in the HTTP\nheaders do not match the corresponding values in the request body, or\nbecause required headers are missing or malformed. For HTTP, the response\nstatus code MUST be `400 Bad Request`.", + "properties": { + "error": { + "allOf": [ + { + "$ref": "#/$defs/Error" + }, + { + "properties": { + "code": { + "const": -32020, + "type": "integer" + } + }, + "required": [ + "code" + ], + "type": "object" + } + ] + }, + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "jsonrpc" + ], + "type": "object" + }, + "Icon": { + "description": "An optionally-sized icon that can be displayed in a user interface.", + "properties": { + "mimeType": { + "description": "Optional MIME type override if the source MIME type is missing or generic.\nFor example: `\"image/png\"`, `\"image/jpeg\"`, or `\"image/svg+xml\"`.", + "type": "string" + }, + "sizes": { + "description": "Optional array of strings that specify sizes at which the icon can be used.\nEach string should be in WxH format (e.g., `\"48x48\"`, `\"96x96\"`) or `\"any\"` for scalable formats like SVG.\n\nIf not provided, the client should assume that the icon can be used at any size.", + "items": { + "type": "string" + }, + "type": "array" + }, + "src": { + "description": "A standard URI pointing to an icon resource. May be an HTTP/HTTPS URL or a\n`data:` URI with Base64-encoded image data.\n\nConsumers SHOULD take steps to ensure URLs serving icons are from the\nsame domain as the client/server or a trusted domain.\n\nConsumers SHOULD take appropriate precautions when consuming SVGs as they can contain\nexecutable JavaScript.", + "format": "uri", + "type": "string" + }, + "theme": { + "description": "Optional specifier for the theme this icon is designed for. `\"light\"` indicates\nthe icon is designed to be used with a light background, and `\"dark\"` indicates\nthe icon is designed to be used with a dark background.\n\nIf not provided, the client should assume the icon can be used with any theme.", + "enum": [ + "dark", + "light" + ], + "type": "string" + } + }, + "required": [ + "src" + ], + "type": "object" + }, + "Icons": { + "description": "Base interface to add `icons` property.", + "properties": { + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + } + }, + "type": "object" + }, + "ImageContent": { + "description": "An image provided to or from an LLM.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "data": { + "description": "The base64-encoded image data.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the image. Different providers may support different image types.", + "type": "string" + }, + "type": { + "const": "image", + "type": "string" + } + }, + "required": [ + "data", + "mimeType", + "type" + ], + "type": "object" + }, + "Implementation": { + "description": "Describes the MCP implementation.", + "properties": { + "description": { + "description": "An optional human-readable description of what this implementation does.\n\nThis can be used by clients or servers to provide context about their purpose\nand capabilities. For example, a server might describe the types of resources\nor tools it provides, while a client might describe its intended use case.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "version": { + "description": "The version of this implementation.", + "type": "string" + }, + "websiteUrl": { + "description": "An optional URL of the website for this implementation.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, + "InputRequest": { + "anyOf": [ + { + "$ref": "#/$defs/CreateMessageRequest" + }, + { + "$ref": "#/$defs/ListRootsRequest" + }, + { + "$ref": "#/$defs/ElicitRequest" + } + ] + }, + "InputRequests": { + "additionalProperties": { + "$ref": "#/$defs/InputRequest" + }, + "description": "A map of server-initiated requests that the client must fulfill.\nKeys are server-assigned identifiers; values are the request objects.", + "type": "object" + }, + "InputRequiredResult": { + "description": "An InputRequiredResult sent by the server to indicate that additional input is needed\nbefore the request can be completed.\n\nAt least one of `inputRequests` or `requestState` MUST be present.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "inputRequests": { + "$ref": "#/$defs/InputRequests" + }, + "requestState": { + "type": "string" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + } + }, + "required": [ + "resultType" + ], + "type": "object" + }, + "InputResponse": { + "anyOf": [ + { + "$ref": "#/$defs/CreateMessageResult" + }, + { + "$ref": "#/$defs/ListRootsResult" + }, + { + "$ref": "#/$defs/ElicitResult" + } + ] + }, + "InputResponseRequestParams": { + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "inputResponses": { + "$ref": "#/$defs/InputResponses" + }, + "requestState": { + "type": "string" + } + }, + "required": [ + "_meta" + ], + "type": "object" + }, + "InputResponses": { + "additionalProperties": { + "$ref": "#/$defs/InputResponse" + }, + "description": "A map of client responses to server-initiated requests.\nKeys correspond to the keys in the {@link InputRequests} map;\nvalues are the client's result for each request.", + "type": "object" + }, + "InternalError": { + "description": "A JSON-RPC error indicating that an internal error occurred on the receiver. This error is returned when the receiver encounters an unexpected condition that prevents it from fulfilling the request.", + "properties": { + "code": { + "const": -32603, + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "InvalidParamsError": { + "description": "A JSON-RPC error indicating that the method parameters are invalid or malformed.\n\nIn MCP, this error is returned in various contexts when request parameters fail validation:\n\n- **Tools**: Unknown tool name or invalid tool arguments\n- **Prompts**: Unknown prompt name or missing required arguments\n- **Pagination**: Invalid or expired cursor values\n- **Logging**: Invalid log level\n- **Elicitation**: Server requests an elicitation mode not declared in client capabilities\n- **Sampling**: Missing tool result or tool results mixed with other content", + "properties": { + "code": { + "const": -32602, + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "InvalidRequestError": { + "description": "A JSON-RPC error indicating that the request is not a valid request object. This error is returned when the message structure does not conform to the JSON-RPC 2.0 specification requirements for a request (e.g., missing required fields like `jsonrpc` or `method`, or using invalid types for these fields).", + "properties": { + "code": { + "const": -32600, + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "JSONArray": { + "items": { + "$ref": "#/$defs/JSONValue" + }, + "type": "array" + }, + "JSONObject": { + "additionalProperties": { + "$ref": "#/$defs/JSONValue" + }, + "type": "object" + }, + "JSONRPCErrorResponse": { + "description": "A response to a request that indicates an error occurred.", + "properties": { + "error": { + "$ref": "#/$defs/Error" + }, + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "jsonrpc" + ], + "type": "object" + }, + "JSONRPCMessage": { + "anyOf": [ + { + "$ref": "#/$defs/JSONRPCRequest" + }, + { + "$ref": "#/$defs/JSONRPCNotification" + }, + { + "$ref": "#/$defs/JSONRPCResultResponse" + }, + { + "$ref": "#/$defs/JSONRPCErrorResponse" + } + ], + "description": "Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent." + }, + "JSONRPCNotification": { + "description": "A notification which does not expect a response.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCRequest": { + "description": "A request that expects a response.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCResponse": { + "anyOf": [ + { + "$ref": "#/$defs/JSONRPCResultResponse" + }, + { + "$ref": "#/$defs/JSONRPCErrorResponse" + } + ], + "description": "A response to a request, containing either the result or error." + }, + "JSONRPCResultResponse": { + "description": "A successful (non-error) response to a request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/Result" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "JSONValue": { + "anyOf": [ + { + "$ref": "#/$defs/JSONObject" + }, + { + "items": { + "$ref": "#/$defs/JSONValue" + }, + "type": "array" + }, + { + "type": [ + "string", + "integer", + "boolean" + ] + } + ] + }, + "LegacyTitledEnumSchema": { + "description": "Use {@link TitledSingleSelectEnumSchema} instead.\nThis interface will be removed in a future version.", + "properties": { + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "enumNames": { + "description": "(Legacy) Display names for enum values.\nNon-standard according to JSON schema 2020-12.", + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "ListPromptsRequest": { + "description": "Sent from the client to request a list of prompts and prompt templates the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "prompts/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ListPromptsResult": { + "description": "The result returned by the server for a {@link ListPromptsRequestprompts/list} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "prompts": { + "items": { + "$ref": "#/$defs/Prompt" + }, + "type": "array" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "prompts", + "resultType", + "ttlMs" + ], + "type": "object" + }, + "ListPromptsResultResponse": { + "description": "A successful response from the server for a {@link ListPromptsRequestprompts/list} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/ListPromptsResult" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "ListResourceTemplatesRequest": { + "description": "Sent from the client to request a list of resource templates the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/templates/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ListResourceTemplatesResult": { + "description": "The result returned by the server for a {@link ListResourceTemplatesRequestresources/templates/list} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resourceTemplates": { + "items": { + "$ref": "#/$defs/ResourceTemplate" + }, + "type": "array" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "resourceTemplates", + "resultType", + "ttlMs" + ], + "type": "object" + }, + "ListResourceTemplatesResultResponse": { + "description": "A successful response from the server for a {@link ListResourceTemplatesRequestresources/templates/list} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/ListResourceTemplatesResult" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "ListResourcesRequest": { + "description": "Sent from the client to request a list of resources the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ListResourcesResult": { + "description": "The result returned by the server for a {@link ListResourcesRequestresources/list} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resources": { + "items": { + "$ref": "#/$defs/Resource" + }, + "type": "array" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "resources", + "resultType", + "ttlMs" + ], + "type": "object" + }, + "ListResourcesResultResponse": { + "description": "A successful response from the server for a {@link ListResourcesRequestresources/list} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/ListResourcesResult" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "ListRootsRequest": { + "description": "Sent from the server to request a list of root URIs from the client. Roots allow\nservers to ask for specific directories or files to operate on. A common example\nfor roots is providing a set of repositories or directories a server should operate\non.\n\nThis request is typically used when the server needs to understand the file system\nstructure or access specific locations that the client has permission to read from.", + "properties": { + "method": { + "const": "roots/list", + "type": "string" + }, + "params": { + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "ListRootsResult": { + "description": "The result returned by the client for a {@link ListRootsRequestroots/list} request.\nThis result contains an array of {@link Root} objects, each representing a root directory\nor file that the server can operate on.", + "properties": { + "roots": { + "items": { + "$ref": "#/$defs/Root" + }, + "type": "array" + } + }, + "required": [ + "roots" + ], + "type": "object" + }, + "ListToolsRequest": { + "description": "Sent from the client to request a list of tools the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tools/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ListToolsResult": { + "description": "The result returned by the server for a {@link ListToolsRequesttools/list} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "tools": { + "items": { + "$ref": "#/$defs/Tool" + }, + "type": "array" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "resultType", + "tools", + "ttlMs" + ], + "type": "object" + }, + "ListToolsResultResponse": { + "description": "A successful response from the server for a {@link ListToolsRequesttools/list} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/ListToolsResult" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "LoggingLevel": { + "description": "The severity of a log message.\n\nThese map to syslog message severities, as specified in RFC-5424:\nhttps://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1", + "enum": [ + "alert", + "critical", + "debug", + "emergency", + "error", + "info", + "notice", + "warning" + ], + "type": "string" + }, + "LoggingMessageNotification": { + "description": "JSONRPCNotification of a log message passed from server to client. The client opts in by setting `\"io.modelcontextprotocol/logLevel\"` in a request's `_meta`.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/message", + "type": "string" + }, + "params": { + "$ref": "#/$defs/LoggingMessageNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "LoggingMessageNotificationParams": { + "description": "Parameters for a `notifications/message` notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/NotificationMetaObject" + }, + "data": { + "description": "The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here." + }, + "level": { + "$ref": "#/$defs/LoggingLevel", + "description": "The severity of this log message." + }, + "logger": { + "description": "An optional name of the logger issuing this message.", + "type": "string" + } + }, + "required": [ + "data", + "level" + ], + "type": "object" + }, + "MetaObject": { + "description": "Represents the contents of a `_meta` field, which clients and servers use to attach additional metadata to their interactions.\n\nCertain key names are reserved by MCP for protocol-level metadata; implementations MUST NOT make assumptions about values at these keys. Additionally, specific schema definitions may reserve particular names for purpose-specific metadata, as declared in those definitions.\n\nValid keys have two segments:\n\n**Prefix:**\n- Optional — if specified, MUST be a series of _labels_ separated by dots (`.`), followed by a slash (`/`).\n- Labels MUST start with a letter and end with a letter or digit. Interior characters may be letters, digits, or hyphens (`-`).\n- Implementations SHOULD use reverse DNS notation (e.g., `com.example/` rather than `example.com/`).\n- Any prefix where the second label is `modelcontextprotocol` or `mcp` is **reserved** for MCP use. For example: `io.modelcontextprotocol/`, `dev.mcp/`, `org.modelcontextprotocol.api/`, and `com.mcp.tools/` are all reserved. However, `com.example.mcp/` is NOT reserved, as the second label is `example`.\n\n**Name:**\n- Unless empty, MUST start and end with an alphanumeric character (`[a-z0-9A-Z]`).\n- Interior characters may be alphanumeric, hyphens (`-`), underscores (`_`), or dots (`.`).", + "type": "object" + }, + "MethodNotFoundError": { + "description": "A JSON-RPC error indicating that the requested method does not exist or is not available.\n\nIn MCP, a server returns this error when a client invokes a method the server does not implement — either a genuinely unknown method, or one gated behind a server capability the server did not advertise (e.g., calling `prompts/list` when the `prompts` capability was not advertised).\n\nA request that requires a client capability the client did not declare is signalled instead by {@link MissingRequiredClientCapabilityError} (`-32021`).", + "properties": { + "code": { + "const": -32601, + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "MissingRequiredClientCapabilityError": { + "description": "Returned when processing a request requires a capability the client did not\ndeclare in `clientCapabilities`. For HTTP, the response status code MUST be\n`400 Bad Request`.", + "properties": { + "error": { + "allOf": [ + { + "$ref": "#/$defs/Error" + }, + { + "properties": { + "code": { + "const": -32021, + "type": "integer" + }, + "data": { + "properties": { + "requiredCapabilities": { + "$ref": "#/$defs/ClientCapabilities", + "description": "The capabilities the server requires from the client to process this request." + } + }, + "required": [ + "requiredCapabilities" + ], + "type": "object" + } + }, + "required": [ + "code", + "data" + ], + "type": "object" + } + ] + }, + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "jsonrpc" + ], + "type": "object" + }, + "ModelHint": { + "description": "Hints to use for model selection.\n\nKeys not declared here are currently left unspecified by the spec and are up\nto the client to interpret.", + "properties": { + "name": { + "description": "A hint for a model name.\n\nThe client SHOULD treat this as a substring of a model name; for example:\n - `claude-3-5-sonnet` should match `claude-3-5-sonnet-20241022`\n - `sonnet` should match `claude-3-5-sonnet-20241022`, `claude-3-sonnet-20240229`, etc.\n - `claude` should match any Claude model\n\nThe client MAY also map the string to a different provider's model name or a different model family, as long as it fills a similar niche; for example:\n - `gemini-1.5-flash` could match `claude-3-haiku-20240307`", + "type": "string" + } + }, + "type": "object" + }, + "ModelPreferences": { + "description": "The server's preferences for model selection, requested of the client during sampling.\n\nBecause LLMs can vary along multiple dimensions, choosing the \"best\" model is\nrarely straightforward. Different models excel in different areas—some are\nfaster but less capable, others are more capable but more expensive, and so\non. This interface allows servers to express their priorities across multiple\ndimensions to help clients make an appropriate selection for their use case.\n\nThese preferences are always advisory. The client MAY ignore them. It is also\nup to the client to decide how to interpret these preferences and how to\nbalance them against other considerations.", + "properties": { + "costPriority": { + "description": "How much to prioritize cost when selecting a model. A value of 0 means cost\nis not important, while a value of 1 means cost is the most important\nfactor.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "hints": { + "description": "Optional hints to use for model selection.\n\nIf multiple hints are specified, the client MUST evaluate them in order\n(such that the first match is taken).\n\nThe client SHOULD prioritize these hints over the numeric priorities, but\nMAY still use the priorities to select from ambiguous matches.", + "items": { + "$ref": "#/$defs/ModelHint" + }, + "type": "array" + }, + "intelligencePriority": { + "description": "How much to prioritize intelligence and capabilities when selecting a\nmodel. A value of 0 means intelligence is not important, while a value of 1\nmeans intelligence is the most important factor.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "speedPriority": { + "description": "How much to prioritize sampling speed (latency) when selecting a model. A\nvalue of 0 means speed is not important, while a value of 1 means speed is\nthe most important factor.", + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "MultiSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + } + ] + }, + "Notification": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "NotificationMetaObject": { + "description": "Extends {@link MetaObject} with additional notification-specific fields. All key naming rules from `MetaObject` apply.", + "properties": { + "io.modelcontextprotocol/subscriptionId": { + "$ref": "#/$defs/RequestId", + "description": "Identifies the subscription stream a notification was delivered on. The\nserver MUST include this key on every notification delivered via a\n{@link SubscriptionsListenRequestsubscriptions/listen} stream, so the\nclient can correlate the notification with the originating subscription.\nThe key is absent on notifications not delivered via a subscription\nstream (e.g. progress notifications for an in-flight request), which is\nwhy it is optional here.\n\nThe value is the JSON-RPC ID of the `subscriptions/listen` request that\nopened the stream." + } + }, + "type": "object" + }, + "NotificationParams": { + "description": "Common params for any notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/NotificationMetaObject" + } + }, + "type": "object" + }, + "NumberSchema": { + "properties": { + "default": { + "type": "number" + }, + "description": { + "type": "string" + }, + "maximum": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "title": { + "type": "string" + }, + "type": { + "enum": [ + "integer", + "number" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "PaginatedRequest": { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "PaginatedRequestParams": { + "description": "Common params for paginated requests.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "cursor": { + "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", + "type": "string" + } + }, + "required": [ + "_meta" + ], + "type": "object" + }, + "PaginatedResult": { + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + } + }, + "required": [ + "resultType" + ], + "type": "object" + }, + "ParseError": { + "description": "A JSON-RPC error indicating that invalid JSON was received by the server. This error is returned when the server cannot parse the JSON text of a message.", + "properties": { + "code": { + "const": -32700, + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "PrimitiveSchemaDefinition": { + "anyOf": [ + { + "$ref": "#/$defs/StringSchema" + }, + { + "$ref": "#/$defs/NumberSchema" + }, + { + "$ref": "#/$defs/BooleanSchema" + }, + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/LegacyTitledEnumSchema" + } + ], + "description": "Restricted schema definitions that only allow primitive types\nwithout nested objects or arrays." + }, + "ProgressNotification": { + "description": "An out-of-band notification used to inform the receiver of a progress update for a long-running request.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/progress", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ProgressNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ProgressNotificationParams": { + "description": "Parameters for a {@link ProgressNotificationnotifications/progress} notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/NotificationMetaObject" + }, + "message": { + "description": "An optional message describing the current progress.", + "type": "string" + }, + "progress": { + "description": "The progress thus far. This should increase every time progress is made, even if the total is unknown.", + "type": "number" + }, + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "The progress token which was given in the initial request, used to associate this notification with the request that is proceeding." + }, + "total": { + "description": "Total number of items to process (or total progress required), if known.", + "type": "number" + } + }, + "required": [ + "progress", + "progressToken" + ], + "type": "object" + }, + "ProgressToken": { + "description": "A progress token, used to associate progress notifications with the original request.", + "type": [ + "string", + "integer" + ] + }, + "Prompt": { + "description": "A prompt or prompt template that the server offers.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "arguments": { + "description": "A list of arguments to use for templating the prompt.", + "items": { + "$ref": "#/$defs/PromptArgument" + }, + "type": "array" + }, + "description": { + "description": "An optional description of what this prompt provides", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "PromptArgument": { + "description": "Describes an argument that a prompt can accept.", + "properties": { + "description": { + "description": "A human-readable description of the argument.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "required": { + "description": "Whether this argument must be provided.", + "type": "boolean" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "PromptListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This is only delivered on a {@link SubscriptionsListenRequestsubscriptions/listen} stream when the client requested it via the `promptsListChanged` filter field.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/prompts/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "PromptMessage": { + "description": "Describes a message returned as part of a prompt.\n\nThis is similar to {@link SamplingMessage}, but also supports the embedding of\nresources from the MCP server.", + "properties": { + "content": { + "$ref": "#/$defs/ContentBlock" + }, + "role": { + "$ref": "#/$defs/Role" + } + }, + "required": [ + "content", + "role" + ], + "type": "object" + }, + "PromptReference": { + "description": "Identifies a prompt.", + "properties": { + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "type": { + "const": "ref/prompt", + "type": "string" + } + }, + "required": [ + "name", + "type" + ], + "type": "object" + }, + "ReadResourceRequest": { + "description": "Sent from the client to the server, to read a specific resource URI.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/read", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ReadResourceRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ReadResourceRequestParams": { + "description": "Parameters for a `resources/read` request.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "inputResponses": { + "$ref": "#/$defs/InputResponses" + }, + "requestState": { + "type": "string" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "_meta", + "uri" + ], + "type": "object" + }, + "ReadResourceResult": { + "description": "The result returned by the server for a {@link ReadResourceRequestresources/read} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "contents": { + "items": { + "anyOf": [ + { + "$ref": "#/$defs/TextResourceContents" + }, + { + "$ref": "#/$defs/BlobResourceContents" + } + ] + }, + "type": "array" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "contents", + "resultType", + "ttlMs" + ], + "type": "object" + }, + "ReadResourceResultResponse": { + "description": "A successful response from the server for a {@link ReadResourceRequestresources/read} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "anyOf": [ + { + "$ref": "#/$defs/InputRequiredResult" + }, + { + "$ref": "#/$defs/ReadResourceResult" + } + ] + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "Request": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "RequestId": { + "description": "A uniquely identifying ID for a request in JSON-RPC.", + "type": [ + "string", + "integer" + ] + }, + "RequestMetaObject": { + "description": "Extends {@link MetaObject} with additional request-specific fields. All key naming rules from `MetaObject` apply.", + "properties": { + "io.modelcontextprotocol/clientCapabilities": { + "$ref": "#/$defs/ClientCapabilities", + "description": "The client's capabilities for this specific request. Required.\n\nCapabilities are declared per-request rather than once at initialization;\nan empty object means the client supports no optional capabilities.\nServers MUST NOT infer capabilities from prior requests." + }, + "io.modelcontextprotocol/clientInfo": { + "$ref": "#/$defs/Implementation", + "description": "Identifies the client software making the request. Required.\n\nThe {@link Implementation} schema requires `name` and `version`; other\nfields are optional." + }, + "io.modelcontextprotocol/logLevel": { + "$ref": "#/$defs/LoggingLevel", + "description": "The desired log level for this request. Optional.\n\nIf absent, the server MUST NOT send any {@link LoggingMessageNotificationnotifications/message}\nnotifications for this request. The client opts in to log messages by\nexplicitly setting a level. Replaces the former `logging/setLevel` RPC." + }, + "io.modelcontextprotocol/protocolVersion": { + "description": "The MCP Protocol Version being used for this request. Required.\n\nFor the HTTP transport, this value MUST match the `MCP-Protocol-Version`\nheader; otherwise the server MUST return a `400 Bad Request`. If the\nserver does not support the requested version, it MUST return an\n{@link UnsupportedProtocolVersionError}.", + "type": "string" + }, + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by {@link ProgressNotificationnotifications/progress}). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "required": [ + "io.modelcontextprotocol/clientCapabilities", + "io.modelcontextprotocol/clientInfo", + "io.modelcontextprotocol/protocolVersion" + ], + "type": "object" + }, + "RequestParams": { + "description": "Common params for any request.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + } + }, + "required": [ + "_meta" + ], + "type": "object" + }, + "Resource": { + "description": "A known resource that the server is capable of reading.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this resource represents.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window usage.", + "type": "integer" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "uri" + ], + "type": "object" + }, + "ResourceContents": { + "description": "The contents of a specific resource or sub-resource.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "ResourceLink": { + "description": "A resource that the server is capable of reading, included in a prompt or tool call result.\n\nNote: resource links returned by tools are not guaranteed to appear in the results of {@link ListResourcesRequestresources/list} requests.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this resource represents.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window usage.", + "type": "integer" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "type": { + "const": "resource_link", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "type", + "uri" + ], + "type": "object" + }, + "ResourceListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This is only delivered on a {@link SubscriptionsListenRequestsubscriptions/listen} stream when the client requested it via the `resourcesListChanged` filter field.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/resources/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "ResourceRequestParams": { + "description": "Common params for resource-related requests.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "_meta", + "uri" + ], + "type": "object" + }, + "ResourceTemplate": { + "description": "A template description for resources available on the server.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this template is for.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "uriTemplate": { + "description": "A URI template (according to RFC 6570) that can be used to construct resource URIs.", + "format": "uri-template", + "type": "string" + } + }, + "required": [ + "name", + "uriTemplate" + ], + "type": "object" + }, + "ResourceTemplateReference": { + "description": "A reference to a resource or resource template definition.", + "properties": { + "type": { + "const": "ref/resource", + "type": "string" + }, + "uri": { + "description": "The URI or URI template of the resource.", + "format": "uri-template", + "type": "string" + } + }, + "required": [ + "type", + "uri" + ], + "type": "object" + }, + "ResourceUpdatedNotification": { + "description": "A notification from the server to the client, informing it that a resource has changed and may need to be read again. This is only sent for resources the client opted in to via the `resourceSubscriptions` field of a {@link SubscriptionsListenRequestsubscriptions/listen} request.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/resources/updated", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ResourceUpdatedNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ResourceUpdatedNotificationParams": { + "description": "Parameters for a `notifications/resources/updated` notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/NotificationMetaObject" + }, + "uri": { + "description": "The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "Result": { + "additionalProperties": {}, + "description": "Common result fields.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + } + }, + "required": [ + "resultType" + ], + "type": "object" + }, + "ResultType": { + "description": "Indicates the type of a {@link Result} object, allowing the client to\ndetermine how to parse the response.\n\ncomplete - the request completed successfully and the result contains the final content.\ninput_required - the request requires additional input and the result contains an {@link InputRequiredResult} object with instructions for the client to provide additional input before retrying the original request.", + "type": "string" + }, + "Role": { + "description": "The sender or recipient of messages and data in a conversation.", + "enum": [ + "assistant", + "user" + ], + "type": "string" + }, + "Root": { + "description": "Represents a root directory or file that the server can operate on.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "name": { + "description": "An optional name for the root. This can be used to provide a human-readable\nidentifier for the root, which may be useful for display purposes or for\nreferencing the root in other parts of the application.", + "type": "string" + }, + "uri": { + "description": "The URI identifying the root. This *must* start with `file://` for now.\nThis restriction may be relaxed in future versions of the protocol to allow\nother URI schemes.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "SamplingMessage": { + "description": "Describes a message issued to or received from an LLM API.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "content": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + }, + { + "items": { + "$ref": "#/$defs/SamplingMessageContentBlock" + }, + "type": "array" + } + ] + }, + "role": { + "$ref": "#/$defs/Role" + } + }, + "required": [ + "content", + "role" + ], + "type": "object" + }, + "SamplingMessageContentBlock": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + } + ] + }, + "ServerCapabilities": { + "description": "Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities.", + "properties": { + "completions": { + "$ref": "#/$defs/JSONObject", + "description": "Present if the server supports argument autocompletion suggestions." + }, + "experimental": { + "additionalProperties": { + "$ref": "#/$defs/JSONObject" + }, + "description": "Experimental, non-standard capabilities that the server supports.", + "type": "object" + }, + "extensions": { + "additionalProperties": { + "$ref": "#/$defs/JSONObject" + }, + "description": "Optional MCP extensions that the server supports. Keys are extension identifiers\n(e.g., \"io.modelcontextprotocol/tasks\"), and values are per-extension settings\nobjects. An empty object indicates support with no settings.\n\nKeys MUST follow the {@link MetaObject`_meta` key naming rules}, with a\nmandatory prefix.", + "type": "object" + }, + "logging": { + "$ref": "#/$defs/JSONObject", + "description": "Present if the server supports sending log messages to the client." + }, + "prompts": { + "description": "Present if the server offers any prompt templates.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the prompt list.", + "type": "boolean" + } + }, + "type": "object" + }, + "resources": { + "description": "Present if the server offers any resources to read.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the resource list.", + "type": "boolean" + }, + "subscribe": { + "description": "Whether this server supports subscribing to resource updates.", + "type": "boolean" + } + }, + "type": "object" + }, + "tools": { + "description": "Present if the server offers any tools to call.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the tool list.", + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "ServerNotification": { + "anyOf": [ + { + "$ref": "#/$defs/CancelledNotification" + }, + { + "$ref": "#/$defs/ProgressNotification" + }, + { + "$ref": "#/$defs/ResourceListChangedNotification" + }, + { + "$ref": "#/$defs/SubscriptionsAcknowledgedNotification" + }, + { + "$ref": "#/$defs/ResourceUpdatedNotification" + }, + { + "$ref": "#/$defs/PromptListChangedNotification" + }, + { + "$ref": "#/$defs/ToolListChangedNotification" + }, + { + "$ref": "#/$defs/LoggingMessageNotification" + } + ] + }, + "ServerResult": { + "anyOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "$ref": "#/$defs/InputRequiredResult" + }, + { + "$ref": "#/$defs/DiscoverResult" + }, + { + "$ref": "#/$defs/ListResourcesResult" + }, + { + "$ref": "#/$defs/ListResourceTemplatesResult" + }, + { + "$ref": "#/$defs/ReadResourceResult" + }, + { + "$ref": "#/$defs/SubscriptionsListenResult" + }, + { + "$ref": "#/$defs/ListPromptsResult" + }, + { + "$ref": "#/$defs/GetPromptResult" + }, + { + "$ref": "#/$defs/ListToolsResult" + }, + { + "$ref": "#/$defs/CallToolResult" + }, + { + "$ref": "#/$defs/CompleteResult" + } + ] + }, + "SingleSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + } + ] + }, + "StringSchema": { + "properties": { + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "format": { + "enum": [ + "date", + "date-time", + "email", + "uri" + ], + "type": "string" + }, + "maxLength": { + "type": "integer" + }, + "minLength": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "SubscriptionFilter": { + "description": "The set of notification types a client may opt in to on a\n{@link SubscriptionsListenRequestsubscriptions/listen} request.\n\nEach notification type is **opt-in**; the server **MUST NOT** send\nnotification types the client has not explicitly requested here.", + "properties": { + "promptsListChanged": { + "description": "If true, receive {@link PromptListChangedNotificationnotifications/prompts/list_changed}.", + "type": "boolean" + }, + "resourceSubscriptions": { + "description": "Subscribe to {@link ResourceUpdatedNotificationnotifications/resources/updated} for these resource URIs.\nReplaces the former `resources/subscribe` RPC.", + "items": { + "type": "string" + }, + "type": "array" + }, + "resourcesListChanged": { + "description": "If true, receive {@link ResourceListChangedNotificationnotifications/resources/list_changed}.", + "type": "boolean" + }, + "toolsListChanged": { + "description": "If true, receive {@link ToolListChangedNotificationnotifications/tools/list_changed}.", + "type": "boolean" + } + }, + "type": "object" + }, + "SubscriptionsAcknowledgedNotification": { + "description": "Sent by the server as the first message on a\n{@link SubscriptionsListenRequestsubscriptions/listen} stream to acknowledge\nthat the subscription has been established and to report which notification\ntypes it agreed to honor.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/subscriptions/acknowledged", + "type": "string" + }, + "params": { + "$ref": "#/$defs/SubscriptionsAcknowledgedNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "SubscriptionsAcknowledgedNotificationParams": { + "description": "Parameters for a {@link SubscriptionsAcknowledgedNotificationnotifications/subscriptions/acknowledged} notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/NotificationMetaObject" + }, + "notifications": { + "$ref": "#/$defs/SubscriptionFilter", + "description": "The subset of requested notification types the server agreed to honor.\nOnly includes notification types the server actually supports; if the\nclient requested an unsupported type (e.g., `promptsListChanged` when\nthe server has no prompts), it is omitted from this set." + } + }, + "required": [ + "notifications" + ], + "type": "object" + }, + "SubscriptionsListenRequest": { + "description": "Sent from the client to open a long-lived channel for receiving notifications\noutside the context of a specific request. Replaces the previous HTTP GET\nendpoint and ensures consistent behavior between HTTP and STDIO.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "subscriptions/listen", + "type": "string" + }, + "params": { + "$ref": "#/$defs/SubscriptionsListenRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "SubscriptionsListenRequestParams": { + "description": "Parameters for a {@link SubscriptionsListenRequestsubscriptions/listen} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "notifications": { + "$ref": "#/$defs/SubscriptionFilter", + "description": "The notifications the client opts in to on this stream. The server\n**MUST NOT** send notification types the client has not explicitly\nrequested." + } + }, + "required": [ + "_meta", + "notifications" + ], + "type": "object" + }, + "SubscriptionsListenResult": { + "description": "The response to a {@link SubscriptionsListenRequestsubscriptions/listen}\nrequest, signalling that the subscription has ended gracefully (for example,\nduring server shutdown). Because the listen stream is long-lived, this result\nis sent only when the server tears the subscription down; an abrupt transport\nclose carries no response. The result body is otherwise empty.", + "properties": { + "_meta": { + "$ref": "#/$defs/SubscriptionsListenResultMeta" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + } + }, + "required": [ + "_meta", + "resultType" + ], + "type": "object" + }, + "SubscriptionsListenResultMeta": { + "description": "Extends {@link MetaObject} with the subscription-stream identifier carried by a\n{@link SubscriptionsListenResult}. All key naming rules from `MetaObject` apply.", + "properties": { + "io.modelcontextprotocol/subscriptionId": { + "$ref": "#/$defs/RequestId", + "description": "Identifies the subscription stream this response closes, so the client can\ncorrelate it with the originating subscription — mirroring the same key on\nthe stream's notifications. The value is the JSON-RPC ID of the\n`subscriptions/listen` request that opened the stream (and equals this\nresponse's `id`)." + } + }, + "required": [ + "io.modelcontextprotocol/subscriptionId" + ], + "type": "object" + }, + "TextContent": { + "description": "Text provided to or from an LLM.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "text": { + "description": "The text content of the message.", + "type": "string" + }, + "type": { + "const": "text", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "type": "object" + }, + "TextResourceContents": { + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "text": { + "description": "The text of the item. This must only be set if the item can actually be represented as text (not binary data).", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "text", + "uri" + ], + "type": "object" + }, + "TitledMultiSelectEnumSchema": { + "description": "Schema for multiple-selection enumeration with display titles for each option.", + "properties": { + "default": { + "description": "Optional default value.", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "items": { + "description": "Schema for array items with enum options and display labels.", + "properties": { + "anyOf": { + "description": "Array of enum options with values and display labels.", + "items": { + "properties": { + "const": { + "description": "The constant enum value.", + "type": "string" + }, + "title": { + "description": "Display title for this option.", + "type": "string" + } + }, + "required": [ + "const", + "title" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "anyOf" + ], + "type": "object" + }, + "maxItems": { + "description": "Maximum number of items to select.", + "type": "integer" + }, + "minItems": { + "description": "Minimum number of items to select.", + "type": "integer" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "array", + "type": "string" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "TitledSingleSelectEnumSchema": { + "description": "Schema for single-selection enumeration with display titles for each option.", + "properties": { + "default": { + "description": "Optional default value.", + "type": "string" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "oneOf": { + "description": "Array of enum options with values and display labels.", + "items": { + "properties": { + "const": { + "description": "The enum value.", + "type": "string" + }, + "title": { + "description": "Display label for this option.", + "type": "string" + } + }, + "required": [ + "const", + "title" + ], + "type": "object" + }, + "type": "array" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "oneOf", + "type" + ], + "type": "object" + }, + "Tool": { + "description": "Definition for a tool the client can call.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/ToolAnnotations", + "description": "Optional additional tool information.\n\nDisplay name precedence order is: `title`, `annotations.title`, then `name`." + }, + "description": { + "description": "A human-readable description of the tool.\n\nThis can be used by clients to improve the LLM's understanding of available tools. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "inputSchema": { + "additionalProperties": {}, + "description": "A JSON Schema object defining the expected parameters for the tool.\n\nTool arguments are always JSON objects, so `type: \"object\"` is required at the root.\nBeyond that, any JSON Schema 2020-12 keyword may appear alongside `type` — including\ncomposition keywords (`oneOf`, `anyOf`, `allOf`, `not`), conditional keywords\n(`if`/`then`/`else`), reference keywords (`$ref`, `$defs`, `$anchor`), and any other\nstandard validation or annotation keywords.\n\nProperty schemas may carry an `x-mcp-header` annotation to mirror the\nargument value into an HTTP header on the Streamable HTTP transport. See\nthe Streamable HTTP transport specification for the validity and\nextraction rules.\n\nDefaults to JSON Schema 2020-12 when no explicit `$schema` is provided.", + "properties": { + "$schema": { + "type": "string" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "outputSchema": { + "additionalProperties": {}, + "description": "An optional JSON Schema object defining the structure of the tool's output returned in\nthe structuredContent field of a {@link CallToolResult}. This can be any valid JSON Schema 2020-12.\n\nDefaults to JSON Schema 2020-12 when no explicit `$schema` is provided.", + "properties": { + "$schema": { + "type": "string" + } + }, + "type": "object" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "inputSchema", + "name" + ], + "type": "object" + }, + "ToolAnnotations": { + "description": "Additional properties describing a {@link Tool} to clients.\n\nNOTE: all properties in `ToolAnnotations` are **hints**.\nThey are not guaranteed to provide a faithful description of\ntool behavior (including descriptive properties like `title`).\n\nClients should never make tool use decisions based on `ToolAnnotations`\nreceived from untrusted servers.", + "properties": { + "destructiveHint": { + "description": "If true, the tool may perform destructive updates to its environment.\nIf false, the tool performs only additive updates.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: true", + "type": "boolean" + }, + "idempotentHint": { + "description": "If true, calling the tool repeatedly with the same arguments\nwill have no additional effect on its environment.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: false", + "type": "boolean" + }, + "openWorldHint": { + "description": "If true, this tool may interact with an \"open world\" of external\nentities. If false, the tool's domain of interaction is closed.\nFor example, the world of a web search tool is open, whereas that\nof a memory tool is not.\n\nDefault: true", + "type": "boolean" + }, + "readOnlyHint": { + "description": "If true, the tool does not modify its environment.\n\nDefault: false", + "type": "boolean" + }, + "title": { + "description": "A human-readable title for the tool.", + "type": "string" + } + }, + "type": "object" + }, + "ToolChoice": { + "description": "Controls tool selection behavior for sampling requests.", + "properties": { + "mode": { + "description": "Controls the tool use ability of the model:\n- `\"auto\"`: Model decides whether to use tools (default)\n- `\"required\"`: Model MUST use at least one tool before completing\n- `\"none\"`: Model MUST NOT use any tools", + "enum": [ + "auto", + "none", + "required" + ], + "type": "string" + } + }, + "type": "object" + }, + "ToolListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of tools it offers has changed. This is only delivered on a {@link SubscriptionsListenRequestsubscriptions/listen} stream when the client requested it via the `toolsListChanged` filter field.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/tools/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "ToolResultContent": { + "description": "The result of a tool use, provided by the user back to the assistant.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject", + "description": "Optional metadata about the tool result. Clients SHOULD preserve this field when\nincluding tool results in subsequent sampling requests to enable caching optimizations." + }, + "content": { + "description": "The unstructured result content of the tool use.\n\nThis has the same format as {@link CallToolResult.content} and can include text, images,\naudio, resource links, and embedded resources.", + "items": { + "$ref": "#/$defs/ContentBlock" + }, + "type": "array" + }, + "isError": { + "description": "Whether the tool use resulted in an error.\n\nIf true, the content typically describes the error that occurred.\nDefault: false", + "type": "boolean" + }, + "structuredContent": { + "description": "An optional structured result value.\n\nThis can be any JSON value (object, array, string, number, boolean, or null).\nIf the tool defined an {@link Tool.outputSchema}, this SHOULD conform to that schema." + }, + "toolUseId": { + "description": "The ID of the tool use this result corresponds to.\n\nThis MUST match the ID from a previous {@link ToolUseContent}.", + "type": "string" + }, + "type": { + "const": "tool_result", + "type": "string" + } + }, + "required": [ + "content", + "toolUseId", + "type" + ], + "type": "object" + }, + "ToolUseContent": { + "description": "A request from the assistant to call a tool.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject", + "description": "Optional metadata about the tool use. Clients SHOULD preserve this field when\nincluding tool uses in subsequent sampling requests to enable caching optimizations." + }, + "id": { + "description": "A unique identifier for this tool use.\n\nThis ID is used to match tool results to their corresponding tool uses.", + "type": "string" + }, + "input": { + "additionalProperties": {}, + "description": "The arguments to pass to the tool, conforming to the tool's input schema.", + "type": "object" + }, + "name": { + "description": "The name of the tool to call.", + "type": "string" + }, + "type": { + "const": "tool_use", + "type": "string" + } + }, + "required": [ + "id", + "input", + "name", + "type" + ], + "type": "object" + }, + "UnsupportedProtocolVersionError": { + "description": "Returned when the request's protocol version is unknown to the server or\nunsupported (e.g., a known experimental or draft version the server has\nchosen not to implement). For HTTP, the response status code MUST be\n`400 Bad Request`.", + "properties": { + "error": { + "allOf": [ + { + "$ref": "#/$defs/Error" + }, + { + "properties": { + "code": { + "const": -32022, + "type": "integer" + }, + "data": { + "properties": { + "requested": { + "description": "The protocol version that was requested by the client.", + "type": "string" + }, + "supported": { + "description": "Protocol versions the server supports. The client should choose a\nmutually supported version from this list and retry.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "requested", + "supported" + ], + "type": "object" + } + }, + "required": [ + "code", + "data" + ], + "type": "object" + } + ] + }, + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "jsonrpc" + ], + "type": "object" + }, + "UntitledMultiSelectEnumSchema": { + "description": "Schema for multiple-selection enumeration without display titles for options.", + "properties": { + "default": { + "description": "Optional default value.", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "items": { + "description": "Schema for the array items.", + "properties": { + "enum": { + "description": "Array of enum values to choose from.", + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "maxItems": { + "description": "Maximum number of items to select.", + "type": "integer" + }, + "minItems": { + "description": "Minimum number of items to select.", + "type": "integer" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "array", + "type": "string" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "UntitledSingleSelectEnumSchema": { + "description": "Schema for single-selection enumeration without display titles for options.", + "properties": { + "default": { + "description": "Optional default value.", + "type": "string" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "enum": { + "description": "Array of enum values to choose from.", + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + } + } +} diff --git a/schema/PINNED.json b/schema/PINNED.json new file mode 100644 index 0000000..9b1739d --- /dev/null +++ b/schema/PINNED.json @@ -0,0 +1,14 @@ +[ + { + "protocol_version": "2025-11-25", + "source_path_in_spec_repo": "schema/2025-11-25/schema.json", + "spec_commit": "6d441518de8a9d5adbab0b10a76a667a63f90665", + "sha256": "4e01628360a2149892eab8f298ceee626d24a58862184eb8ec85d95b8f353e31" + }, + { + "protocol_version": "2026-07-28", + "source_path_in_spec_repo": "schema/draft/schema.json", + "spec_commit": "ead35b59b4fda8b32e276810025d8f92bdcec1b6", + "sha256": "e00f675287e8cf078688c26c8a89d283ff2613da3b76d5cd15aff9d189df639c" + } +] diff --git a/schema/README.md b/schema/README.md new file mode 100644 index 0000000..7bb2145 --- /dev/null +++ b/schema/README.md @@ -0,0 +1,13 @@ +# Vendored protocol schemas + +JSON Schema files for each protocol version the SDK has a wire-shape surface +package for, vendored from the [spec repository] at the commit recorded in +`PINNED.json`. `scripts/gen_surface_types.py` reads these to regenerate +`src/mcp-types/mcp_types/v/__init__.py`; CI runs the generator with +`--check`. + +To bump: drop the new `schema.json` here as `.json`, update +the matching entry in `PINNED.json` (commit + sha256), and run +`uv run --frozen --group codegen python scripts/gen_surface_types.py`. + +[spec repository]: https://github.com/modelcontextprotocol/modelcontextprotocol diff --git a/scripts/build-docs.sh b/scripts/build-docs.sh new file mode 100755 index 0000000..8286786 --- /dev/null +++ b/scripts/build-docs.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# +# Build combined v1 + v2 documentation for GitHub Pages. +# +# v1 docs (from the v1.x branch) are placed at the site root. +# v2 docs (from main) are placed under /v2/. +# +# The two lines use different toolchains: v1.x still builds with MkDocs, while +# main builds with Zensical (which needs a pre-build step to materialise the API +# reference and a post-build step for llms.txt — see scripts/docs/). Each branch +# is fetched fresh from origin and built with its own synced `docs` group, so +# the output is identical regardless of which branch triggered the workflow. +# This script is intended to run in CI; for a local v2 preview use +# `scripts/serve-docs.sh`. +# +# Usage: +# scripts/build-docs.sh [output-dir] +# +# Default output directory: site +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +OUTPUT_DIR="$(cd "$REPO_ROOT" && mkdir -p "${1:-site}" && cd "${1:-site}" && pwd)" +V1_WORKTREE="$REPO_ROOT/.worktrees/v1-docs" +V2_WORKTREE="$REPO_ROOT/.worktrees/v2-docs" + +cleanup() { + cd "$REPO_ROOT" + git worktree remove --force "$V1_WORKTREE" 2>/dev/null || true + git worktree remove --force "$V2_WORKTREE" 2>/dev/null || true + rmdir "$REPO_ROOT/.worktrees" 2>/dev/null || true +} +trap cleanup EXIT + +# Build the checked-out worktree into its local `site/`, picking the toolchain +# from the branch's own files rather than hard-coding it here: a branch that +# ships the Zensical build recipe (scripts/docs/build.sh) builds with it, +# otherwise it falls back to MkDocs. This keeps the combined build correct +# regardless of which branch triggered it. Zensical requires site_dir to live +# within the project root, so both paths build to the local `site/` and let +# the caller copy it to its destination. +build_site() { + if [[ -f scripts/docs/build.sh ]]; then + bash scripts/docs/build.sh + else + uv sync --frozen --group docs + NO_MKDOCS_2_WARNING=1 uv run --frozen --no-sync mkdocs build --site-dir site + fi +} + +build_branch() { + local branch="$1" worktree="$2" dest="$3" + + echo "=== Building docs for ${branch} ===" + git fetch origin "$branch" + git worktree remove --force "$worktree" 2>/dev/null || true + rm -rf "$worktree" + git worktree add --detach "$worktree" "origin/${branch}" + + ( + cd "$worktree" + rm -rf site + build_site + mkdir -p "$dest" + cp -a site/. "$dest/" + ) +} + +rm -rf "${OUTPUT_DIR:?}"/* + +build_branch v1.x "$V1_WORKTREE" "$OUTPUT_DIR" +build_branch main "$V2_WORKTREE" "$OUTPUT_DIR/v2" + +echo "=== Combined docs built at $OUTPUT_DIR ===" diff --git a/scripts/docs/build.sh b/scripts/docs/build.sh new file mode 100755 index 0000000..8dce3af --- /dev/null +++ b/scripts/docs/build.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# +# Build the v2 documentation site for this checkout into `site/`. +# +# Zensical runs no MkDocs plugins or hooks, so the build is three steps: +# materialise the API reference pages and the concrete config, build the +# site strictly, then generate llms.txt and the per-page markdown +# renditions. This script is the single owner of that recipe, dependency +# sync included — CI (shared.yml, docs-preview.yml) and scripts/build-docs.sh +# all call it. The toolchain detection in docs-preview.yml and build-docs.sh +# keys on this file's path and expects the site under site/. +# +# Usage: +# scripts/docs/build.sh +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Snippet includes (`--8<--`) resolve against the working directory, which +# must therefore be the repo root. +cd "$SCRIPT_DIR/../.." + +uv sync --frozen --group docs + +# Zensical's incremental cache is unsound: a warm rebuild where only some +# pages re-render silently drops cross-references to cache-hit pages, and +# HTML for since-deleted pages lingers in site/. Build cold so the output +# (and the checks below) are deterministic. +rm -rf .cache site + +uv run --frozen --no-sync python scripts/docs/build_config.py +uv run --frozen --no-sync zensical build -f mkdocs.gen.yml --strict + +# Zensical stays green even under --strict when a cross-reference fails to +# resolve (rendered as literal bracket text) or an objects.inv inventory +# fails to download (every link through it silently degrades to plain text); +# MkDocs strict mode aborted on both. Validate the built site instead. +uv run --frozen --no-sync python scripts/docs/check_crossrefs.py --site-dir site + +uv run --frozen --no-sync python scripts/docs/llms_txt.py --site-dir site diff --git a/scripts/docs/build_config.py b/scripts/docs/build_config.py new file mode 100644 index 0000000..daba648 --- /dev/null +++ b/scripts/docs/build_config.py @@ -0,0 +1,90 @@ +"""Produce the concrete Zensical build config from `mkdocs.yml`. + +Zensical builds from `mkdocs.yml` directly, but it has no equivalent of +mkdocs-literate-nav: the "API Reference" navigation has to be materialised +as explicit entries. This script regenerates the `docs/api/` tree (via +gen_ref_pages) and writes `mkdocs.gen.yml` with the real API nav spliced +in — that generated file is what `zensical build`/`serve` consumes. + +Usage: + python scripts/docs/build_config.py +""" + +from __future__ import annotations + +import posixpath +import re +from pathlib import Path + +# Both scripts live in this directory, which Python puts on sys.path[0] when +# `build_config.py` is run directly (its documented invocation). +import gen_ref_pages +import yaml + +ROOT = Path(__file__).parent.parent.parent + +# A scheme-prefixed nav value (https:, mailto:, ...) is an external link, not +# a page path (same classifier as llms_txt.py; a `://` test would misread +# scheme-only URIs as pages). +_EXTERNAL = re.compile(r"[a-zA-Z][a-zA-Z0-9+.-]*:") + + +def _nav_pages(nav: list) -> set[str]: + """Collect every local page reference in the nav (external links excluded).""" + pages: set[str] = set() + for entry in nav: + value = next(iter(entry.values())) if isinstance(entry, dict) else entry + if isinstance(value, list): + pages |= _nav_pages(value) + elif not _EXTERNAL.match(value): + pages.add(value) + return pages + + +def _validate_nav(nav: list, docs_dir: Path) -> None: + """Fail on nav/page drift in either direction. + + Zensical (0.0.48) ships a nav entry for a nonexistent page as a broken + link without any diagnostic even under --strict, and publishes a page + that no nav entry reaches as unreachable orphan HTML; MkDocs aborted the + build on both (--strict with `validation.omitted_files: warn`). + Validating here keeps those guarantees. The generated `api/` tree is + exempt from the orphan check: its nav is spliced in from the same + generator that writes the files, so it cannot drift. + """ + pages = _nav_pages(nav) + # Containment before existence: `docs_dir / page` would happily resolve + # an absolute value or a `../` escape against the wrong root. + if escaping := sorted(p for p in pages if p.startswith("/") or posixpath.normpath(p).startswith("..")): + raise SystemExit(f"build_config: nav references pages outside docs/: {escaping}") + if missing := sorted(page for page in pages if not (docs_dir / page).is_file()): + raise SystemExit(f"build_config: nav references pages that don't exist under docs/: {missing}") + # Dot-directories (e.g. `.overrides` theme files) are not pages: the site + # builder ignores them, so the orphan check must too. + relative = (page.relative_to(docs_dir) for page in docs_dir.rglob("*.md")) + on_disk = {page.as_posix() for page in relative if not any(part.startswith(".") for part in page.parts)} + if orphaned := sorted(page for page in on_disk - pages if not page.startswith("api/")): + raise SystemExit(f"build_config: pages under docs/ that no nav entry reaches: {orphaned}") + + +def build_config() -> None: + config = yaml.safe_load((ROOT / "mkdocs.yml").read_text(encoding="utf-8")) + + api_nav = gen_ref_pages.generate() + if not api_nav: + raise SystemExit("build_config: gen_ref_pages produced no API pages — did the src/ layout move?") + for entry in config["nav"]: + if isinstance(entry, dict) and "API Reference" in entry: + entry["API Reference"] = api_nav + break + else: + raise SystemExit("build_config: no 'API Reference' entry found in mkdocs.yml nav") + + _validate_nav(config["nav"], ROOT / "docs") + + output = ROOT / "mkdocs.gen.yml" + output.write_text(yaml.safe_dump(config, sort_keys=False, allow_unicode=True), encoding="utf-8") + + +if __name__ == "__main__": + build_config() diff --git a/scripts/docs/check_crossrefs.py b/scripts/docs/check_crossrefs.py new file mode 100644 index 0000000..39f866a --- /dev/null +++ b/scripts/docs/check_crossrefs.py @@ -0,0 +1,170 @@ +"""Fail the docs build when a page's cross-references did not resolve. + +Zensical (0.0.48) stays green even under `--strict` on two failure modes +MkDocs strict mode aborted on: + +- An unresolvable `[text][identifier]` cross-reference renders as literal + bracket text (mkdocs-autorefs used to warn). The generated API index and + the docstring cross-references rely on such references resolving. +- A failed `objects.inv` inventory download is logged as an ERROR record and + otherwise ignored, silently degrading every link through that inventory + (thousands of standard-library links alone) to plain text. + +Both are caught from the built site itself, so no log-wording change can +disarm the check: an unresolved reference leaves a tell-tale bracket +sequence in prose text (code blocks legitimately contain `][`, e.g. dict +indexing, so only text outside `
`/`` counts), and every inventory
+declared in `mkdocs.yml` must contribute at least one resolved reference —
+an `autorefs-external` anchor, which hand-authored prose links to the same
+host never carry — to the site (an inventory that contributes none is dead
+config and fails too).
+
+Offline contributors can skip the inventory check by setting
+`DOCS_ALLOW_INVENTORY_FAILURE=1`; CI (`CI=true`) never skips it.
+
+Usage:
+    python scripts/docs/check_crossrefs.py --site-dir site
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import re
+import sys
+from html.parser import HTMLParser
+from pathlib import Path
+from urllib.parse import urlsplit
+
+import yaml
+
+ROOT = Path(__file__).parent.parent.parent
+
+# Unresolved cross-reference tell-tales in extracted prose (`\x00` marks a
+# skipped code element, see _ProseTextExtractor): the two-part
+# `[text][identifier]` reconstruction — the identifier part is always plain
+# text, so a code mark inside the second brackets means indexing prose like
+# `data[`x`][`y`]`, not a reference — and the shortcut `[`identifier`]` form,
+# which extracts as `[\x00]` unless a preceding word character or bracket
+# makes it a subscript like `list[`str`]`.
+_UNRESOLVED = re.compile(r"\]\[[^\]\s\x00]*\]|(?]*autorefs-external[^>]*>")
+
+
+class _ProseTextExtractor(HTMLParser):
+    """Collect text outside 
//