diff --git a/.claude/commands/review-pr.md b/.claude/commands/review-pr.md new file mode 100644 index 000000000..114c1e3d5 --- /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/.github/ISSUE_TEMPLATE/bug.yaml b/.github/ISSUE_TEMPLATE/bug.yaml index e52277a2a..617ff9872 100644 --- a/.github/ISSUE_TEMPLATE/bug.yaml +++ b/.github/ISSUE_TEMPLATE/bug.yaml @@ -39,7 +39,7 @@ body: demonstrating the bug. placeholder: | - from mcp.server.fastmcp import FastMCP + from mcp.server.mcpserver import MCPServer ... render: Python diff --git a/.github/actions/conformance/client.py b/.github/actions/conformance/client.py new file mode 100644 index 000000000..58f684f01 --- /dev/null +++ b/.github/actions/conformance/client.py @@ -0,0 +1,367 @@ +"""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) + - 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 + elicitation-sep1034-client-defaults - Elicitation with default accept callback + auth/client-credentials-jwt - Client credentials with private_key_jwt + auth/client-credentials-basic - Client credentials with client_secret_basic + 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 +from pydantic import AnyUrl + +from mcp import ClientSession, types +from mcp.client.auth import OAuthClientProvider, TokenStorage +from mcp.client.auth.extensions.client_credentials import ( + ClientCredentialsOAuthProvider, + PrivateKeyJWTOAuthProvider, + SignedJWTParameters, +) +from mcp.client.context import ClientRequestContext +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import 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__) + +# 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 + + 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 + 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) -> tuple[str, str | None]: + """Return the captured auth code and state.""" + if self._auth_code is None: + raise RuntimeError("No authorization code available - was handle_redirect called?") + auth_code = self._auth_code + state = self._state + self._auth_code = None + self._state = None + return auth_code, state + + +# --- Scenario Handlers --- + + +@register("initialize") +async def run_initialize(server_url: str) -> None: + """Connect, initialize, list tools, close.""" + async with streamable_http_client(url=server_url) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + logger.debug("Initialized successfully") + await session.list_tools() + logger.debug("Listed tools successfully") + + +@register("tools_call") +async def run_tools_call(server_url: str) -> None: + """Connect, initialize, list tools, call add_numbers(a=5, b=3), close.""" + async with streamable_http_client(url=server_url) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + await session.list_tools() + result = await session.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, initialize, list tools, call test_reconnection, close.""" + async with streamable_http_client(url=server_url) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + await session.list_tools() + result = await session.call_tool("test_reconnection", {}) + logger.debug(f"test_reconnection result: {result}") + + +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) + + +@register("elicitation-sep1034-client-defaults") +async def run_elicitation_defaults(server_url: str) -> None: + """Connect with elicitation callback that applies schema defaults.""" + async with streamable_http_client(url=server_url) as (read_stream, write_stream): + async with ClientSession( + read_stream, write_stream, elicitation_callback=default_elicitation_callback + ) as session: + await session.initialize() + await session.list_tools() + result = await session.call_tool("test_client_elicitation_defaults", {}) + logger.debug(f"test_client_elicitation_defaults result: {result}") + + +@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) + + +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: OAuthClientProvider) -> None: + """Common session logic for all OAuth flows.""" + client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0) + async with streamable_http_client(url=server_url, http_client=client) as (read_stream, write_stream): + async with ClientSession( + read_stream, write_stream, elicitation_callback=default_elicitation_callback + ) as session: + await session.initialize() + logger.debug("Initialized successfully") + + tools_result = await session.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 session.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") + + 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/run-server.sh b/.github/actions/conformance/run-server.sh new file mode 100755 index 000000000..01af13612 --- /dev/null +++ b/.github/actions/conformance/run-server.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -e + +PORT="${PORT:-3001}" +SERVER_URL="http://localhost:${PORT}/mcp" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/../../.." + +# Start everything-server +uv run --frozen mcp-everything-server --port "$PORT" & +SERVER_PID=$! +trap "kill $SERVER_PID 2>/dev/null || true; wait $SERVER_PID 2>/dev/null || true" EXIT + +# Wait for server to be ready +MAX_RETRIES=30 +RETRY_COUNT=0 +while ! curl -s "$SERVER_URL" > /dev/null 2>&1; do + 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" + +# Run conformance tests +npx @modelcontextprotocol/conformance@0.1.10 server --url "$SERVER_URL" "$@" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..00dc69828 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: monthly + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 000000000..514f979d7 --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,33 @@ +# Source: https://github.com/anthropics/claude-code-action/blob/main/docs/code-review.md +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + +jobs: + claude-review: + # Fork PRs don't have access to secrets or OIDC tokens, so the action + # cannot authenticate. See https://github.com/anthropics/claude-code-action/issues/339 + if: github.event.pull_request.head.repo.fork == false && github.actor != 'dependabot[bot]' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@2f8ba26a219c06cfb0f468eef8d97055fa814f97 # v1.0.53 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + plugin_marketplaces: "https://github.com/anthropics/claude-code.git" + plugins: "code-review@claude-code-plugins" + prompt: "/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}" diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 000000000..8421cf954 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,41 @@ +# 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')) || + (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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@2f8ba26a219c06cfb0f468eef8d97055fa814f97 # v1.0.53 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + use_commit_signing: true + additional_permissions: | + actions: read diff --git a/.github/workflows/comment-on-release.yml b/.github/workflows/comment-on-release.yml new file mode 100644 index 000000000..15d6a1d26 --- /dev/null +++ b/.github/workflows/comment-on-release.yml @@ -0,0 +1,149 @@ +name: Comment on PRs in Release + +on: + release: + types: [published] + +permissions: + pull-requests: write + contents: read + +jobs: + comment-on-prs: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 + + - name: Get previous release + id: previous_release + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const currentTag = '${{ github.event.release.tag_name }}'; + + // Get all releases + const { data: releases } = await github.rest.repos.listReleases({ + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100 + }); + + // Find current release index + const currentIndex = releases.findIndex(r => r.tag_name === currentTag); + + if (currentIndex === -1) { + console.log('Current release not found in list'); + return null; + } + + // Get previous release (next in the list since they're sorted by date desc) + const previousRelease = releases[currentIndex + 1]; + + if (!previousRelease) { + console.log('No previous release found, this might be the first release'); + return null; + } + + console.log(`Found previous release: ${previousRelease.tag_name}`); + + return previousRelease.tag_name; + + - name: Get merged PRs between releases + id: get_prs + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const currentTag = '${{ github.event.release.tag_name }}'; + const previousTag = ${{ steps.previous_release.outputs.result }}; + + if (!previousTag) { + console.log('No previous release found, skipping'); + return []; + } + + console.log(`Finding PRs between ${previousTag} and ${currentTag}`); + + // Get commits between previous and current release + const comparison = await github.rest.repos.compareCommits({ + owner: context.repo.owner, + repo: context.repo.repo, + base: previousTag, + head: currentTag + }); + + const commits = comparison.data.commits; + console.log(`Found ${commits.length} commits`); + + // Get PRs associated with each commit using GitHub API + const prNumbers = new Set(); + + for (const commit of commits) { + try { + const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: commit.sha + }); + + for (const pr of prs) { + if (pr.merged_at) { + prNumbers.add(pr.number); + console.log(`Found merged PR: #${pr.number}`); + } + } + } catch (error) { + console.log(`Failed to get PRs for commit ${commit.sha}: ${error.message}`); + } + } + + console.log(`Found ${prNumbers.size} merged PRs`); + return Array.from(prNumbers); + + - name: Comment on PRs + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const prNumbers = ${{ steps.get_prs.outputs.result }}; + const releaseTag = '${{ github.event.release.tag_name }}'; + const releaseUrl = '${{ github.event.release.html_url }}'; + + const comment = `This pull request is included in [${releaseTag}](${releaseUrl})`; + + let commentedCount = 0; + + for (const prNumber of prNumbers) { + try { + // Check if we've already commented on this PR for this release + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + per_page: 100 + }); + + const alreadyCommented = comments.some(c => + c.user.type === 'Bot' && c.body.includes(releaseTag) + ); + + if (alreadyCommented) { + console.log(`Skipping PR #${prNumber} - already commented for ${releaseTag}`); + continue; + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: comment + }); + commentedCount++; + console.log(`Successfully commented on PR #${prNumber}`); + } catch (error) { + console.error(`Failed to comment on PR #${prNumber}:`, error.message); + } + } + + console.log(`Commented on ${commentedCount} of ${prNumbers.length} PRs`); diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml new file mode 100644 index 000000000..d876da00b --- /dev/null +++ b/.github/workflows/conformance.yml @@ -0,0 +1,45 @@ +name: Conformance Tests + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: conformance-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + server-conformance: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1 + with: + enable-cache: true + version: 0.9.5 + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: 24 + - run: uv sync --frozen --all-extras --package mcp-everything-server + - run: ./.github/actions/conformance/run-server.sh + + client-conformance: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1 + with: + enable-cache: true + version: 0.9.5 + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: 24 + - run: uv sync --frozen --all-extras --package mcp + - run: npx @modelcontextprotocol/conformance@0.1.13 client --command 'uv run --frozen python .github/actions/conformance/client.py' --suite all diff --git a/.github/workflows/main-checks.yml b/.github/workflows/main-checks.yml deleted file mode 100644 index 6f38043cd..000000000 --- a/.github/workflows/main-checks.yml +++ /dev/null @@ -1,13 +0,0 @@ -name: Main branch checks - -on: - push: - branches: - - main - - "v*.*.*" - tags: - - "v*.*.*" - -jobs: - checks: - uses: ./.github/workflows/shared.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 000000000..d34e438fc --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: ["main", "v1.x"] + tags: ["v*.*.*"] + pull_request: + branches: ["main", "v1.x"] + +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-docs-manually.yml b/.github/workflows/publish-docs-manually.yml index befe44d31..ee45ab5c8 100644 --- a/.github/workflows/publish-docs-manually.yml +++ b/.github/workflows/publish-docs-manually.yml @@ -9,20 +9,20 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Configure Git Credentials run: | git config user.name github-actions[bot] git config user.email 41898282+github-actions[bot]@users.noreply.github.com - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1 with: enable-cache: true version: 0.9.5 - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - - uses: actions/cache@v4 + - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: key: mkdocs-material-${{ env.cache_id }} path: .cache @@ -31,3 +31,5 @@ jobs: - run: uv sync --frozen --group docs - run: uv run --frozen --no-sync mkdocs gh-deploy --force + env: + ENABLE_SOCIAL_CARDS: "true" diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 59ede8417..bfc3cc64e 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -10,10 +10,10 @@ jobs: runs-on: ubuntu-latest needs: [checks] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1 with: enable-cache: true version: 0.9.5 @@ -25,7 +25,7 @@ jobs: run: uv build - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: release-dists path: dist/ @@ -44,13 +44,13 @@ jobs: steps: - name: Retrieve release distributions - uses: actions/download-artifact@v4 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: release-dists path: dist/ - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1 docs-publish: runs-on: ubuntu-latest @@ -58,20 +58,20 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Configure Git Credentials run: | git config user.name github-actions[bot] git config user.email 41898282+github-actions[bot]@users.noreply.github.com - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1 with: enable-cache: true version: 0.9.5 - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - - uses: actions/cache@v4 + - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: key: mkdocs-material-${{ env.cache_id }} path: .cache diff --git a/.github/workflows/pull-request-checks.yml b/.github/workflows/pull-request-checks.yml deleted file mode 100644 index a7e7a8bf1..000000000 --- a/.github/workflows/pull-request-checks.yml +++ /dev/null @@ -1,8 +0,0 @@ -name: Pull request checks - -on: - pull_request: - -jobs: - checks: - uses: ./.github/workflows/shared.yml diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index 531487db5..72e328b54 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -13,20 +13,32 @@ jobs: pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - uses: astral-sh/setup-uv@v7 + - uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1 with: enable-cache: true version: 0.9.5 - name: Install dependencies run: uv sync --frozen --all-extras --python 3.10 - - uses: pre-commit/action@v3.0.1 + - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 with: extra_args: --all-files --verbose env: - SKIP: no-commit-to-branch + SKIP: no-commit-to-branch,readme-v1-frozen + + # TODO(Max): Drop this in v2. + - name: Check README.md is not modified + if: github.event_name == 'pull_request' + run: | + git fetch --no-tags --depth=1 origin "$BASE_SHA" + if git diff --name-only "$BASE_SHA" -- README.md | grep -q .; then + echo "::error::README.md is frozen at v1. Edit README.v2.md instead." + exit 1 + fi + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} test: name: test (${{ matrix.python-version }}, ${{ matrix.dep-resolution.name }}, ${{ matrix.os }}) @@ -35,19 +47,19 @@ jobs: continue-on-error: true strategy: matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] dep-resolution: - name: lowest-direct install-flags: "--upgrade --resolution lowest-direct" - - name: highest - install-flags: "--upgrade --resolution highest" + - name: locked + install-flags: "--frozen" os: [ubuntu-latest, windows-latest] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1 with: enable-cache: true version: 0.9.5 @@ -56,17 +68,22 @@ jobs: run: uv sync ${{ matrix.dep-resolution.install-flags }} --all-extras --python ${{ matrix.python-version }} - name: Run pytest with coverage + shell: bash run: | - uv run --frozen --no-sync coverage run -m pytest + 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@v5 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - uses: astral-sh/setup-uv@v7 + - uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1 with: enable-cache: true version: 0.9.5 @@ -75,4 +92,4 @@ jobs: 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 + run: uv run --frozen scripts/update_readme_snippets.py --check --readme README.v2.md diff --git a/.github/workflows/weekly-lockfile-update.yml b/.github/workflows/weekly-lockfile-update.yml new file mode 100644 index 000000000..96507d793 --- /dev/null +++ b/.github/workflows/weekly-lockfile-update.yml @@ -0,0 +1,40 @@ +name: Weekly Lockfile Update + +on: + workflow_dispatch: + schedule: + # Every Thursday at 8:00 UTC + - cron: "0 8 * * 4" + +permissions: + contents: write + pull-requests: write + +jobs: + update-lockfile: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1 + with: + version: 0.9.5 + + - name: Update lockfile + run: | + echo '## Updated Dependencies' > pr_body.md + echo '' >> pr_body.md + echo '```' >> pr_body.md + uv lock --upgrade 2>&1 | tee -a pr_body.md + echo '```' >> pr_body.md + + - name: Create pull request + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7 + with: + commit-message: "chore: update uv.lock with latest dependencies" + title: "chore: weekly dependency update" + body-path: pr_body.md + branch: weekly-lockfile-update + delete-branch: true + add-paths: uv.lock + labels: dependencies diff --git a/.gitignore b/.gitignore index 2478cac4b..5ff4ce977 100644 --- a/.gitignore +++ b/.gitignore @@ -171,4 +171,4 @@ cython_debug/ **/CLAUDE.local.md # claude code -.claude/ +results/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c06b9028d..42c12fded 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -42,7 +42,7 @@ repos: types: [python] language: system pass_filenames: false - exclude: ^README\.md$ + exclude: ^README(\.v2)?\.md$ - id: pyright name: pyright entry: uv run --frozen pyright @@ -55,9 +55,15 @@ repos: language: system files: ^(pyproject\.toml|uv\.lock)$ pass_filenames: false + # TODO(Max): Drop this in v2. + - id: readme-v1-frozen + name: README.md is frozen (v1 docs) + entry: README.md is frozen at v1. Edit README.v2.md instead. + language: fail + files: ^README\.md$ - id: readme-snippets name: Check README snippets are up to date entry: uv run --frozen python scripts/update_readme_snippets.py --check language: system - files: ^(README\.md|examples/.*\.py|scripts/update_readme_snippets\.py)$ + files: ^(README\.v2\.md|examples/.*\.py|scripts/update_readme_snippets\.py)$ pass_filenames: false diff --git a/CLAUDE.md b/CLAUDE.md index cc2d36060..e48ce6e70 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,9 +6,9 @@ This document contains critical information about working with this codebase. Fo 1. Package Management - ONLY use uv, NEVER pip - - Installation: `uv add package` - - Running tools: `uv run tool` - - Upgrading: `uv add --dev package --upgrade-package package` + - Installation: `uv add <package>` + - Running tools: `uv run <tool>` + - Upgrading: `uv lock --upgrade-package <package>` - FORBIDDEN: `uv pip install`, `@latest` syntax 2. Code Quality @@ -17,13 +17,28 @@ This document contains critical information about working with this codebase. Fo - Functions must be focused and small - Follow existing patterns exactly - Line length: 120 chars maximum + - FORBIDDEN: imports inside functions. THEY SHOULD BE AT THE TOP OF THE FILE. 3. Testing Requirements - Framework: `uv run --frozen pytest` - Async testing: use anyio, not asyncio + - Do not use `Test` prefixed classes, use functions - Coverage: test edge cases and errors - New features require tests - Bug fixes require regression tests + - IMPORTANT: The `tests/client/test_client.py` is the most well designed test file. Follow its patterns. + - IMPORTANT: Be minimal, and focus on E2E tests: Use the `mcp.client.Client` whenever possible. + - IMPORTANT: Before pushing, verify 100% branch coverage on changed files by running + `uv run --frozen pytest -x` (coverage is configured in `pyproject.toml` with `fail_under = 100` + and `branch = true`). If any branch is uncovered, add a test for it before pushing. + - 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 + +Test files mirror the source tree: `src/mcp/client/streamable_http.py` → `tests/client/test_streamable_http.py` +Add tests to the existing file for that module. - For commits fixing bugs or adding features based on user reports add: @@ -51,6 +66,17 @@ This document contains critical information about working with this codebase. Fo - NEVER ever mention a `co-authored-by` or similar aspects. In particular, never mention the tool used to create the commit message or PR. +## Breaking Changes + +When making breaking changes, document them in `docs/migration.md`. 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. + ## Python Tools ## Code Formatting @@ -66,12 +92,11 @@ This document contains critical information about working with this codebase. Fo - Line wrapping: - Strings: use parentheses - Function calls: multi-line with proper indent - - Imports: split into multiple lines + - Imports: try to use a single line 2. Type Checking - Tool: `uv run --frozen pyright` - Requirements: - - Explicit None checks for Optional - Type narrowing for strings - Version warnings can be ignored if checks pass @@ -106,10 +131,6 @@ This document contains critical information about working with this codebase. Fo - Add None checks - Narrow string types - Match existing patterns - - Pytest: - - If the tests aren't finding the anyio pytest mark, try adding PYTEST_DISABLE_PLUGIN_AUTOLOAD="" - to the start of the pytest run command eg: - `PYTEST_DISABLE_PLUGIN_AUTOLOAD="" uv run --frozen pytest` 3. Best Practices - Check git status before commits @@ -127,6 +148,4 @@ This document contains critical information about working with this codebase. Fo - File ops: `except (OSError, PermissionError):` - JSON: `except json.JSONDecodeError:` - Network: `except (ConnectionError, TimeoutError):` -- **Only catch `Exception` for**: - - Top-level handlers that must not crash - - Cleanup blocks (log at debug level) +- **FORBIDDEN** `except Exception:` - unless in top-level handlers diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c18937f5b..64187086f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,43 @@ 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. + +### 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 @@ -23,9 +60,14 @@ uv tool install pre-commit --with pre-commit-uv --force-reinstall ## Development Workflow 1. Choose the correct branch for your changes: - - For bug fixes to a released version: use the latest release branch (e.g. v1.1.x for 1.1.3) - - For new features: use the main branch (which will become the next minor/major version) - - If unsure, ask in an issue first + + | 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 @@ -71,13 +113,29 @@ pre-commit run --all-files - Add type hints to all functions - Include docstrings for public APIs -## Pull Request Process +## 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 + +### Checklist 1. Update documentation as needed 2. Add tests for new functionality 3. Ensure CI passes -4. Maintainers will review your code -5. Address review feedback +4. Address review feedback ## Code of Conduct diff --git a/README.md b/README.md index a43918464..487d48bee 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,14 @@ </div> +<!-- TODO(v2): Replace this README with README.v2.md when v2 is released --> + +> [!NOTE] +> **This README documents v1.x of the MCP Python SDK (the current stable release).** +> +> For v1.x code and documentation, see the [`v1.x` branch](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x). +> For the upcoming v2 documentation (pre-alpha, in development on `main`), see [`README.v2.md`](README.v2.md). + <!-- omit in toc --> ## Table of Contents @@ -2241,12 +2249,12 @@ Run from the repository root: import asyncio from mcp import ClientSession -from mcp.client.streamable_http import streamablehttp_client +from mcp.client.streamable_http import streamable_http_client async def main(): # Connect to a streamable HTTP server - async with streamablehttp_client("http://localhost:8000/mcp") as ( + async with streamable_http_client("http://localhost:8000/mcp") as ( read_stream, write_stream, _, @@ -2370,11 +2378,12 @@ cd to the `examples/snippets` directory and run: import asyncio from urllib.parse import parse_qs, urlparse +import httpx from pydantic import AnyUrl from mcp import ClientSession from mcp.client.auth import OAuthClientProvider, TokenStorage -from mcp.client.streamable_http import streamablehttp_client +from mcp.client.streamable_http import streamable_http_client from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken @@ -2428,15 +2437,16 @@ async def main(): callback_handler=handle_callback, ) - async with streamablehttp_client("http://localhost:8001/mcp", auth=oauth_auth) as (read, write, _): - async with ClientSession(read, write) as session: - await session.initialize() + 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]}") + 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]}") + resources = await session.list_resources() + print(f"Available resources: {[r.uri for r in resources.resources]}") def run(): diff --git a/README.v2.md b/README.v2.md new file mode 100644 index 000000000..bd6927bf9 --- /dev/null +++ b/README.v2.md @@ -0,0 +1,2508 @@ +# 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> + +<!-- TODO(v2): Move this content back to README.md when v2 is released --> + +> [!IMPORTANT] +> **This documents v2 of the SDK (currently in development, pre-alpha on `main`).** +> +> We anticipate a stable v2 release in Q1 2026. Until then, **v1.x remains the recommended version** for production use. v1.x will continue to receive bug fixes and security updates for at least 6 months after v2 ships to give people time to upgrade. +> +> For v1 documentation (the current stable release), see [`README.md`](README.md). + +<!-- omit in toc --> +## Table of Contents + +- [MCP Python SDK](#mcp-python-sdk) + - [Overview](#overview) + - [Installation](#installation) + - [Adding MCP to your python project](#adding-mcp-to-your-python-project) + - [Running the standalone MCP development tools](#running-the-standalone-mcp-development-tools) + - [Quickstart](#quickstart) + - [What is MCP?](#what-is-mcp) + - [Core Concepts](#core-concepts) + - [Server](#server) + - [Resources](#resources) + - [Tools](#tools) + - [Structured Output](#structured-output) + - [Prompts](#prompts) + - [Images](#images) + - [Context](#context) + - [Getting Context in Functions](#getting-context-in-functions) + - [Context Properties and Methods](#context-properties-and-methods) + - [Completions](#completions) + - [Elicitation](#elicitation) + - [Sampling](#sampling) + - [Logging and Notifications](#logging-and-notifications) + - [Authentication](#authentication) + - [MCPServer Properties](#mcpserver-properties) + - [Session Properties and Methods](#session-properties-and-methods) + - [Request Context Properties](#request-context-properties) + - [Running Your Server](#running-your-server) + - [Development Mode](#development-mode) + - [Claude Desktop Integration](#claude-desktop-integration) + - [Direct Execution](#direct-execution) + - [Streamable HTTP Transport](#streamable-http-transport) + - [CORS Configuration for Browser-Based Clients](#cors-configuration-for-browser-based-clients) + - [Mounting to an Existing ASGI Server](#mounting-to-an-existing-asgi-server) + - [StreamableHTTP servers](#streamablehttp-servers) + - [Basic mounting](#basic-mounting) + - [Host-based routing](#host-based-routing) + - [Multiple servers with path configuration](#multiple-servers-with-path-configuration) + - [Path configuration at initialization](#path-configuration-at-initialization) + - [SSE servers](#sse-servers) + - [Advanced Usage](#advanced-usage) + - [Low-Level Server](#low-level-server) + - [Structured Output Support](#structured-output-support) + - [Pagination (Advanced)](#pagination-advanced) + - [Writing MCP Clients](#writing-mcp-clients) + - [Client Display Utilities](#client-display-utilities) + - [OAuth Authentication for Clients](#oauth-authentication-for-clients) + - [Parsing Tool Results](#parsing-tool-results) + - [MCP Primitives](#mcp-primitives) + - [Server Capabilities](#server-capabilities) + - [Documentation](#documentation) + - [Contributing](#contributing) + - [License](#license) + +[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://modelcontextprotocol.github.io/python-sdk/ +[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 + +## Overview + +The Model Context Protocol allows applications to provide context for LLMs in a standardized way, separating the concerns of providing context from the actual LLM interaction. This Python SDK implements the full MCP specification, making it easy to: + +- Build MCP clients that can connect to any MCP server +- Create MCP servers that expose resources, prompts and tools +- Use standard transports like stdio, SSE, and Streamable HTTP +- Handle all MCP protocol messages and lifecycle events + +## Installation + +### Adding MCP to your python project + +We recommend using [uv](https://docs.astral.sh/uv/) to manage your Python projects. + +If you haven't created a uv-managed project yet, create one: + + ```bash + uv init mcp-server-demo + cd mcp-server-demo + ``` + + Then add MCP to your project dependencies: + + ```bash + uv add "mcp[cli]" + ``` + +Alternatively, for projects using pip for dependencies: + +```bash +pip install "mcp[cli]" +``` + +### Running the standalone MCP development tools + +To run the mcp command with uv: + +```bash +uv run mcp +``` + +## Quickstart + +Let's create a simple MCP server that exposes a calculator tool and some data: + +<!-- snippet-source examples/snippets/servers/mcpserver_quickstart.py --> +```python +"""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) +``` + +_Full example: [examples/snippets/servers/mcpserver_quickstart.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/mcpserver_quickstart.py)_ +<!-- /snippet-source --> + +You can install this server in [Claude Code](https://docs.claude.com/en/docs/claude-code/mcp) and interact with it right away. First, run the server: + +```bash +uv run --with mcp examples/snippets/servers/mcpserver_quickstart.py +``` + +Then add it to Claude Code: + +```bash +claude mcp add --transport http my-server http://localhost:8000/mcp +``` + +Alternatively, you can test it with the MCP Inspector. Start the server as above, then in a separate terminal: + +```bash +npx -y @modelcontextprotocol/inspector +``` + +In the inspector UI, connect to `http://localhost:8000/mcp`. + +## What is MCP? + +The [Model Context Protocol (MCP)](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 specifically designed for LLM interactions. MCP servers can: + +- Expose data through **Resources** (think of these sort of like GET endpoints; they are used to load information into the LLM's context) +- Provide functionality through **Tools** (sort of like POST endpoints; they are used to execute code or otherwise produce a side effect) +- Define interaction patterns through **Prompts** (reusable templates for LLM interactions) +- And more! + +## Core Concepts + +### Server + +The MCPServer server is your core interface to the MCP protocol. It handles connection management, protocol compliance, and message routing: + +<!-- snippet-source examples/snippets/servers/lifespan_example.py --> +```python +"""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() +``` + +_Full example: [examples/snippets/servers/lifespan_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lifespan_example.py)_ +<!-- /snippet-source --> + +### Resources + +Resources are how you expose data to LLMs. They're similar to GET endpoints in a REST API - they provide data but shouldn't perform significant computation or have side effects: + +<!-- snippet-source examples/snippets/servers/basic_resource.py --> +```python +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 +}""" +``` + +_Full example: [examples/snippets/servers/basic_resource.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_resource.py)_ +<!-- /snippet-source --> + +### Tools + +Tools let LLMs take actions through your server. Unlike resources, tools are expected to perform computation and have side effects: + +<!-- snippet-source examples/snippets/servers/basic_tool.py --> +```python +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()}" +``` + +_Full example: [examples/snippets/servers/basic_tool.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_tool.py)_ +<!-- /snippet-source --> + +Tools can optionally receive a Context object by including a parameter with the `Context` type annotation. This context is automatically injected by the MCPServer framework and provides access to MCP capabilities: + +<!-- snippet-source examples/snippets/servers/tool_progress.py --> +```python +from mcp.server.mcpserver import Context, MCPServer +from mcp.server.session import ServerSession + +mcp = MCPServer(name="Progress Example") + + +@mcp.tool() +async def long_running_task(task_name: str, ctx: Context[ServerSession, None], steps: int = 5) -> str: + """Execute a task with progress updates.""" + await ctx.info(f"Starting: {task_name}") + + for i in range(steps): + progress = (i + 1) / steps + await ctx.report_progress( + progress=progress, + total=1.0, + message=f"Step {i + 1}/{steps}", + ) + await ctx.debug(f"Completed step {i + 1}") + + return f"Task '{task_name}' completed" +``` + +_Full example: [examples/snippets/servers/tool_progress.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/tool_progress.py)_ +<!-- /snippet-source --> + +#### Structured Output + +Tools will return structured results by default, if their return type +annotation is compatible. Otherwise, they will return unstructured results. + +Structured output supports these return types: + +- Pydantic models (BaseModel subclasses) +- TypedDicts +- Dataclasses and other classes with type hints +- `dict[str, T]` (where T is any JSON-serializable type) +- Primitive types (str, int, float, bool, bytes, None) - wrapped in `{"result": value}` +- Generic types (list, tuple, Union, Optional, etc.) - wrapped in `{"result": value}` + +Classes without type hints cannot be serialized for structured output. Only +classes with properly annotated attributes will be converted to Pydantic models +for schema generation and validation. + +Structured results are automatically validated against the output schema +generated from the annotation. This ensures the tool returns well-typed, +validated data that clients can easily process. + +**Note:** For backward compatibility, unstructured results are also +returned. Unstructured results are provided for backward compatibility +with previous versions of the MCP specification, and are quirks-compatible +with previous versions of MCPServer in the current version of the SDK. + +**Note:** In cases where a tool function's return type annotation +causes the tool to be classified as structured _and this is undesirable_, +the classification can be suppressed by passing `structured_output=False` +to the `@tool` decorator. + +##### Advanced: Direct CallToolResult + +For full control over tool responses including the `_meta` field (for passing data to client applications without exposing it to the model), you can return `CallToolResult` directly: + +<!-- snippet-source examples/snippets/servers/direct_call_tool_result.py --> +```python +"""Example showing direct CallToolResult return for advanced control.""" + +from typing import Annotated + +from pydantic import BaseModel + +from mcp.server.mcpserver import MCPServer +from mcp.types import CallToolResult, TextContent + +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=[]) +``` + +_Full example: [examples/snippets/servers/direct_call_tool_result.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/direct_call_tool_result.py)_ +<!-- /snippet-source --> + +**Important:** `CallToolResult` must always be returned (no `Optional` or `Union`). For empty results, use `CallToolResult(content=[])`. For optional simple types, use `str | None` without `CallToolResult`. + +<!-- snippet-source examples/snippets/servers/structured_output.py --> +```python +"""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} +``` + +_Full example: [examples/snippets/servers/structured_output.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/structured_output.py)_ +<!-- /snippet-source --> + +### Prompts + +Prompts are reusable templates that help LLMs interact with your server effectively: + +<!-- snippet-source examples/snippets/servers/basic_prompt.py --> +```python +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?"), + ] +``` + +_Full example: [examples/snippets/servers/basic_prompt.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_prompt.py)_ +<!-- /snippet-source --> + +### Icons + +MCP servers can provide icons for UI display. Icons can be added to the server implementation, tools, resources, and prompts: + +```python +from mcp.server.mcpserver import MCPServer, Icon + +# Create an icon from a file path or URL +icon = Icon( + src="icon.png", + mimeType="image/png", + sizes="64x64" +) + +# Add icons to server +mcp = MCPServer( + "My Server", + website_url="https://example.com", + icons=[icon] +) + +# Add icons to tools, resources, and prompts +@mcp.tool(icons=[icon]) +def my_tool(): + """Tool with an icon.""" + return "result" + +@mcp.resource("demo://resource", icons=[icon]) +def my_resource(): + """Resource with an icon.""" + return "content" +``` + +_Full example: [examples/mcpserver/icons_demo.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/mcpserver/icons_demo.py)_ + +### Images + +MCPServer provides an `Image` class that automatically handles image data: + +<!-- snippet-source examples/snippets/servers/images.py --> +```python +"""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") +``` + +_Full example: [examples/snippets/servers/images.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/images.py)_ +<!-- /snippet-source --> + +### Context + +The Context object is automatically injected into tool and resource functions that request it via type hints. It provides access to MCP capabilities like logging, progress reporting, resource reading, user interaction, and request metadata. + +#### Getting Context in Functions + +To use context in a tool or resource function, add a parameter with the `Context` type annotation: + +```python +from mcp.server.mcpserver import Context, MCPServer + +mcp = MCPServer(name="Context Example") + + +@mcp.tool() +async def my_tool(x: int, ctx: Context) -> str: + """Tool that uses context capabilities.""" + # The context parameter can have any name as long as it's type-annotated + return await process_with_context(x, ctx) +``` + +#### Context Properties and Methods + +The Context object provides the following capabilities: + +- `ctx.request_id` - Unique ID for the current request +- `ctx.client_id` - Client ID if available +- `ctx.mcp_server` - Access to the MCPServer server instance (see [MCPServer Properties](#mcpserver-properties)) +- `ctx.session` - Access to the underlying session for advanced communication (see [Session Properties and Methods](#session-properties-and-methods)) +- `ctx.request_context` - Access to request-specific data and lifespan resources (see [Request Context Properties](#request-context-properties)) +- `await ctx.debug(message)` - Send debug log message +- `await ctx.info(message)` - Send info log message +- `await ctx.warning(message)` - Send warning log message +- `await ctx.error(message)` - Send error log message +- `await ctx.log(level, message, logger_name=None)` - Send log with custom level +- `await ctx.report_progress(progress, total=None, message=None)` - Report operation progress +- `await ctx.read_resource(uri)` - Read a resource by URI +- `await ctx.elicit(message, schema)` - Request additional information from user with validation + +<!-- snippet-source examples/snippets/servers/tool_progress.py --> +```python +from mcp.server.mcpserver import Context, MCPServer +from mcp.server.session import ServerSession + +mcp = MCPServer(name="Progress Example") + + +@mcp.tool() +async def long_running_task(task_name: str, ctx: Context[ServerSession, None], steps: int = 5) -> str: + """Execute a task with progress updates.""" + await ctx.info(f"Starting: {task_name}") + + for i in range(steps): + progress = (i + 1) / steps + await ctx.report_progress( + progress=progress, + total=1.0, + message=f"Step {i + 1}/{steps}", + ) + await ctx.debug(f"Completed step {i + 1}") + + return f"Task '{task_name}' completed" +``` + +_Full example: [examples/snippets/servers/tool_progress.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/tool_progress.py)_ +<!-- /snippet-source --> + +### Completions + +MCP supports providing completion suggestions for prompt arguments and resource template parameters. With the context parameter, servers can provide completions based on previously resolved values: + +Client usage: + +<!-- snippet-source examples/snippets/clients/completion_client.py --> +```python +"""cd to the `examples/snippets` directory and run: +uv run completion-client +""" + +import asyncio +import os + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client +from mcp.types import PromptReference, ResourceTemplateReference + +# 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() +``` + +_Full example: [examples/snippets/clients/completion_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/completion_client.py)_ +<!-- /snippet-source --> +### Elicitation + +Request additional information from users. This example shows an Elicitation during a Tool Call: + +<!-- snippet-source examples/snippets/servers/elicitation.py --> +```python +"""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 pydantic import BaseModel, Field + +from mcp.server.mcpserver import Context, MCPServer +from mcp.server.session import ServerSession +from mcp.shared.exceptions import UrlElicitationRequiredError +from mcp.types import ElicitRequestURLParams + +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[ServerSession, None]) -> 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[ServerSession, None]) -> 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[ServerSession, None]) -> 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, + ) + ] + ) +``` + +_Full example: [examples/snippets/servers/elicitation.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/elicitation.py)_ +<!-- /snippet-source --> + +Elicitation schemas support default values for all field types. Default values are automatically included in the JSON schema sent to clients, allowing them to pre-populate forms. + +The `elicit()` method returns an `ElicitationResult` with: + +- `action`: "accept", "decline", or "cancel" +- `data`: The validated response (only when accepted) +- `validation_error`: Any validation error message + +### Sampling + +Tools can interact with LLMs through sampling (generating text): + +<!-- snippet-source examples/snippets/servers/sampling.py --> +```python +from mcp.server.mcpserver import Context, MCPServer +from mcp.server.session import ServerSession +from mcp.types import SamplingMessage, TextContent + +mcp = MCPServer(name="Sampling Example") + + +@mcp.tool() +async def generate_poem(topic: str, ctx: Context[ServerSession, None]) -> str: + """Generate a poem using LLM sampling.""" + prompt = f"Write a short poem about {topic}" + + result = await ctx.session.create_message( + messages=[ + SamplingMessage( + role="user", + content=TextContent(type="text", text=prompt), + ) + ], + max_tokens=100, + ) + + # 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) +``` + +_Full example: [examples/snippets/servers/sampling.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/sampling.py)_ +<!-- /snippet-source --> + +### Logging and Notifications + +Tools can send logs and notifications through the context: + +<!-- snippet-source examples/snippets/servers/notifications.py --> +```python +from mcp.server.mcpserver import Context, MCPServer +from mcp.server.session import ServerSession + +mcp = MCPServer(name="Notifications Example") + + +@mcp.tool() +async def process_data(data: str, ctx: Context[ServerSession, None]) -> str: + """Process data with logging.""" + # Different log levels + await ctx.debug(f"Debug: Processing '{data}'") + await ctx.info("Info: Starting processing") + await ctx.warning("Warning: This is experimental") + await ctx.error("Error: (This is just a demo)") + + # Notify about resource changes + await ctx.session.send_resource_list_changed() + + return f"Processed: {data}" +``` + +_Full example: [examples/snippets/servers/notifications.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/notifications.py)_ +<!-- /snippet-source --> + +### Authentication + +Authentication can be used by servers that want to expose tools accessing protected resources. + +`mcp.server.auth` implements OAuth 2.1 resource server functionality, where MCP servers act as Resource Servers (RS) that validate tokens issued by separate Authorization Servers (AS). This follows the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) and implements RFC 9728 (Protected Resource Metadata) for AS discovery. + +MCP servers can use authentication by providing an implementation of the `TokenVerifier` protocol: + +<!-- snippet-source examples/snippets/servers/oauth_server.py --> +```python +"""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) +``` + +_Full example: [examples/snippets/servers/oauth_server.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/oauth_server.py)_ +<!-- /snippet-source --> + +For a complete example with separate Authorization Server and Resource Server implementations, see [`examples/servers/simple-auth/`](examples/servers/simple-auth/). + +**Architecture:** + +- **Authorization Server (AS)**: Handles OAuth flows, user authentication, and token issuance +- **Resource Server (RS)**: Your MCP server that validates tokens and serves protected resources +- **Client**: Discovers AS through RFC 9728, obtains tokens, and uses them with the MCP server + +See [TokenVerifier](src/mcp/server/auth/provider.py) for more details on implementing token validation. + +### MCPServer Properties + +The MCPServer server instance accessible via `ctx.mcp_server` provides access to server configuration and metadata: + +- `ctx.mcp_server.name` - The server's name as defined during initialization +- `ctx.mcp_server.instructions` - Server instructions/description provided to clients +- `ctx.mcp_server.website_url` - Optional website URL for the server +- `ctx.mcp_server.icons` - Optional list of icons for UI display +- `ctx.mcp_server.settings` - Complete server configuration object containing: + - `debug` - Debug mode flag + - `log_level` - Current logging level + - `host` and `port` - Server network configuration + - `sse_path`, `streamable_http_path` - Transport paths + - `stateless_http` - Whether the server operates in stateless mode + - And other configuration options + +```python +@mcp.tool() +def server_info(ctx: Context) -> dict: + """Get information about the current server.""" + return { + "name": ctx.mcp_server.name, + "instructions": ctx.mcp_server.instructions, + "debug_mode": ctx.mcp_server.settings.debug, + "log_level": ctx.mcp_server.settings.log_level, + "host": ctx.mcp_server.settings.host, + "port": ctx.mcp_server.settings.port, + } +``` + +### Session Properties and Methods + +The session object accessible via `ctx.session` provides advanced control over client communication: + +- `ctx.session.client_params` - Client initialization parameters and declared capabilities +- `await ctx.session.send_log_message(level, data, logger)` - Send log messages with full control +- `await ctx.session.create_message(messages, max_tokens)` - Request LLM sampling/completion +- `await ctx.session.send_progress_notification(token, progress, total, message)` - Direct progress updates +- `await ctx.session.send_resource_updated(uri)` - Notify clients that a specific resource changed +- `await ctx.session.send_resource_list_changed()` - Notify clients that the resource list changed +- `await ctx.session.send_tool_list_changed()` - Notify clients that the tool list changed +- `await ctx.session.send_prompt_list_changed()` - Notify clients that the prompt list changed + +```python +@mcp.tool() +async def notify_data_update(resource_uri: str, ctx: Context) -> str: + """Update data and notify clients of the change.""" + # Perform data update logic here + + # Notify clients that this specific resource changed + await ctx.session.send_resource_updated(AnyUrl(resource_uri)) + + # If this affects the overall resource list, notify about that too + await ctx.session.send_resource_list_changed() + + return f"Updated {resource_uri} and notified clients" +``` + +### Request Context Properties + +The request context accessible via `ctx.request_context` contains request-specific information and resources: + +- `ctx.request_context.lifespan_context` - Access to resources initialized during server startup + - Database connections, configuration objects, shared services + - Type-safe access to resources defined in your server's lifespan function +- `ctx.request_context.meta` - Request metadata from the client including: + - `progressToken` - Token for progress notifications + - Other client-provided metadata +- `ctx.request_context.request` - The original MCP request object for advanced processing +- `ctx.request_context.request_id` - Unique identifier for this request + +```python +# Example with typed lifespan context +@dataclass +class AppContext: + db: Database + config: AppConfig + +@mcp.tool() +def query_with_config(query: str, ctx: Context) -> str: + """Execute a query using shared database and configuration.""" + # Access typed lifespan context + app_ctx: AppContext = ctx.request_context.lifespan_context + + # Use shared resources + connection = app_ctx.db + settings = app_ctx.config + + # Execute query with configuration + result = connection.execute(query, timeout=settings.query_timeout) + return str(result) +``` + +_Full lifespan example: [examples/snippets/servers/lifespan_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lifespan_example.py)_ + +## Running Your Server + +### Development Mode + +The fastest way to test and debug your server is with the MCP Inspector: + +```bash +uv run mcp dev server.py + +# Add dependencies +uv run mcp dev server.py --with pandas --with numpy + +# Mount local code +uv run mcp dev server.py --with-editable . +``` + +### Claude Desktop Integration + +Once your server is ready, install it in Claude Desktop: + +```bash +uv run mcp install server.py + +# Custom name +uv run mcp install server.py --name "My Analytics Server" + +# Environment variables +uv run mcp install server.py -v API_KEY=abc123 -v DB_URL=postgres://... +uv run mcp install server.py -f .env +``` + +### Direct Execution + +For advanced scenarios like custom deployments: + +<!-- snippet-source examples/snippets/servers/direct_execution.py --> +```python +"""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() +``` + +_Full example: [examples/snippets/servers/direct_execution.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/direct_execution.py)_ +<!-- /snippet-source --> + +Run it with: + +```bash +python servers/direct_execution.py +# or +uv run mcp run servers/direct_execution.py +``` + +Note that `uv run mcp run` or `uv run mcp dev` only supports server using MCPServer and not the low-level server variant. + +### Streamable HTTP Transport + +> **Note**: Streamable HTTP transport is the recommended transport for production deployments. Use `stateless_http=True` and `json_response=True` for optimal scalability. + +<!-- snippet-source examples/snippets/servers/streamable_config.py --> +```python +"""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") +``` + +_Full example: [examples/snippets/servers/streamable_config.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_config.py)_ +<!-- /snippet-source --> + +You can mount multiple MCPServer servers in a Starlette application: + +<!-- snippet-source examples/snippets/servers/streamable_starlette_mount.py --> +```python +"""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) +``` + +_Full example: [examples/snippets/servers/streamable_starlette_mount.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_starlette_mount.py)_ +<!-- /snippet-source --> + +For low level server with Streamable HTTP implementations, see: + +- Stateful server: [`examples/servers/simple-streamablehttp/`](examples/servers/simple-streamablehttp/) +- Stateless server: [`examples/servers/simple-streamablehttp-stateless/`](examples/servers/simple-streamablehttp-stateless/) + +The streamable HTTP transport supports: + +- Stateful and stateless operation modes +- Resumability with event stores +- JSON or SSE response formats +- Better scalability for multi-node deployments + +#### CORS Configuration for Browser-Based Clients + +If you'd like your server to be accessible by browser-based MCP clients, you'll need to configure CORS headers. The `Mcp-Session-Id` header must be exposed for browser clients to access it: + +```python +from starlette.applications import Starlette +from starlette.middleware.cors import CORSMiddleware + +# Create your Starlette app first +starlette_app = Starlette(routes=[...]) + +# Then wrap it with CORS middleware +starlette_app = CORSMiddleware( + starlette_app, + allow_origins=["*"], # Configure appropriately for production + allow_methods=["GET", "POST", "DELETE"], # MCP streamable HTTP methods + expose_headers=["Mcp-Session-Id"], +) +``` + +This configuration is necessary because: + +- The MCP streamable HTTP transport uses the `Mcp-Session-Id` header for session management +- Browsers restrict access to response headers unless explicitly exposed via CORS +- Without this configuration, browser-based clients won't be able to read the session ID from initialization responses + +### Mounting to an Existing ASGI Server + +By default, SSE servers are mounted at `/sse` and Streamable HTTP servers are mounted at `/mcp`. You can customize these paths using the methods described below. + +For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes). + +#### StreamableHTTP servers + +You can mount the StreamableHTTP server to an existing ASGI server using the `streamable_http_app` method. This allows you to integrate the StreamableHTTP server with other ASGI applications. + +##### Basic mounting + +<!-- snippet-source examples/snippets/servers/streamable_http_basic_mounting.py --> +```python +"""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, +) +``` + +_Full example: [examples/snippets/servers/streamable_http_basic_mounting.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_basic_mounting.py)_ +<!-- /snippet-source --> + +##### Host-based routing + +<!-- snippet-source examples/snippets/servers/streamable_http_host_mounting.py --> +```python +"""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, +) +``` + +_Full example: [examples/snippets/servers/streamable_http_host_mounting.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_host_mounting.py)_ +<!-- /snippet-source --> + +##### Multiple servers with path configuration + +<!-- snippet-source examples/snippets/servers/streamable_http_multiple_servers.py --> +```python +"""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, +) +``` + +_Full example: [examples/snippets/servers/streamable_http_multiple_servers.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_multiple_servers.py)_ +<!-- /snippet-source --> + +##### Path configuration at initialization + +<!-- snippet-source examples/snippets/servers/streamable_http_path_config.py --> +```python +"""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="/"), + ), + ] +) +``` + +_Full example: [examples/snippets/servers/streamable_http_path_config.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_path_config.py)_ +<!-- /snippet-source --> + +#### SSE servers + +> **Note**: SSE transport is being superseded by [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http). + +You can mount the SSE server to an existing ASGI server using the `sse_app` method. This allows you to integrate the SSE server with other ASGI applications. + +```python +from starlette.applications import Starlette +from starlette.routing import Mount, Host +from mcp.server.mcpserver import MCPServer + + +mcp = MCPServer("My App") + +# Mount the SSE server to the existing ASGI server +app = Starlette( + routes=[ + Mount('/', app=mcp.sse_app()), + ] +) + +# or dynamically mount as host +app.router.routes.append(Host('mcp.acme.corp', app=mcp.sse_app())) +``` + +You can also mount multiple MCP servers at different sub-paths. The SSE transport automatically detects the mount path via ASGI's `root_path` mechanism, so message endpoints are correctly routed: + +```python +from starlette.applications import Starlette +from starlette.routing import Mount +from mcp.server.mcpserver import MCPServer + +# Create multiple MCP servers +github_mcp = MCPServer("GitHub API") +browser_mcp = MCPServer("Browser") +search_mcp = MCPServer("Search") + +# Mount each server at its own sub-path +# The SSE transport automatically uses ASGI's root_path to construct +# the correct message endpoint (e.g., /github/messages/, /browser/messages/) +app = Starlette( + routes=[ + Mount("/github", app=github_mcp.sse_app()), + Mount("/browser", app=browser_mcp.sse_app()), + Mount("/search", app=search_mcp.sse_app()), + ] +) +``` + +For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes). + +## Advanced Usage + +### Low-Level Server + +For more control, you can use the low-level server implementation directly. This gives you full access to the protocol and allows you to customize every aspect of your server, including lifecycle management through the lifespan API: + +<!-- snippet-source examples/snippets/servers/lowlevel/lifespan.py --> +```python +"""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.server.stdio +from mcp import types +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()) +``` + +_Full example: [examples/snippets/servers/lowlevel/lifespan.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/lifespan.py)_ +<!-- /snippet-source --> + +The lifespan API provides: + +- A way to initialize resources when the server starts and clean them up when it stops +- Access to initialized resources through the request context in handlers +- Type-safe context passing between lifespan and request handlers + +<!-- snippet-source examples/snippets/servers/lowlevel/basic.py --> +```python +"""Run from the repository root: +uv run examples/snippets/servers/lowlevel/basic.py +""" + +import asyncio + +import mcp.server.stdio +from mcp import types +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()) +``` + +_Full example: [examples/snippets/servers/lowlevel/basic.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/basic.py)_ +<!-- /snippet-source --> + +Caution: The `uv run mcp run` and `uv run mcp dev` tool doesn't support low-level server. + +#### Structured Output Support + +The low-level server supports structured output for tools, allowing you to return both human-readable content and machine-readable structured data. Tools can define an `outputSchema` to validate their structured output: + +<!-- snippet-source examples/snippets/servers/lowlevel/structured_output.py --> +```python +"""Run from the repository root: +uv run examples/snippets/servers/lowlevel/structured_output.py +""" + +import asyncio +import json + +import mcp.server.stdio +from mcp import types +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()) +``` + +_Full example: [examples/snippets/servers/lowlevel/structured_output.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/structured_output.py)_ +<!-- /snippet-source --> + +With the low-level server, handlers always return `CallToolResult` directly. You construct both the human-readable `content` and the machine-readable `structured_content` yourself, giving you full control over the response. + +##### Returning CallToolResult with `_meta` + +For passing data to client applications without exposing it to the model, use the `_meta` field on `CallToolResult`: + +<!-- snippet-source examples/snippets/servers/lowlevel/direct_call_tool_result.py --> +```python +"""Run from the repository root: +uv run examples/snippets/servers/lowlevel/direct_call_tool_result.py +""" + +import asyncio + +import mcp.server.stdio +from mcp import types +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()) +``` + +_Full example: [examples/snippets/servers/lowlevel/direct_call_tool_result.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/direct_call_tool_result.py)_ +<!-- /snippet-source --> + +### Pagination (Advanced) + +For servers that need to handle large datasets, the low-level server provides paginated versions of list operations. This is an optional optimization - most servers won't need pagination unless they're dealing with hundreds or thousands of items. + +#### Server-side Implementation + +<!-- snippet-source examples/snippets/servers/pagination_example.py --> +```python +"""Example of implementing pagination with the low-level MCP server.""" + +from mcp import 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) +``` + +_Full example: [examples/snippets/servers/pagination_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/pagination_example.py)_ +<!-- /snippet-source --> + +#### Client-side Consumption + +<!-- snippet-source examples/snippets/clients/pagination_client.py --> +```python +"""Example of consuming paginated MCP endpoints from a client.""" + +import asyncio + +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client +from mcp.types import PaginatedRequestParams, Resource + + +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()) +``` + +_Full example: [examples/snippets/clients/pagination_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/pagination_client.py)_ +<!-- /snippet-source --> + +#### Key Points + +- **Cursors are opaque strings** - the server defines the format (numeric offsets, timestamps, etc.) +- **Return `nextCursor=None`** when there are no more pages +- **Backward compatible** - clients that don't support pagination will still work (they'll just get the first page) +- **Flexible page sizes** - Each endpoint can define its own page size based on data characteristics + +See the [simple-pagination example](examples/servers/simple-pagination) for a complete implementation. + +### Writing MCP Clients + +The SDK provides a high-level client interface for connecting to MCP servers using various [transports](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports): + +<!-- snippet-source examples/snippets/clients/stdio_client.py --> +```python +"""cd to the `examples/snippets/clients` directory and run: +uv run client +""" + +import asyncio +import os + +from mcp import ClientSession, StdioServerParameters, types +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.TextContent): + 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() +``` + +_Full example: [examples/snippets/clients/stdio_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/stdio_client.py)_ +<!-- /snippet-source --> + +Clients can also connect using [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http): + +<!-- snippet-source examples/snippets/clients/streamable_basic.py --> +```python +"""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()) +``` + +_Full example: [examples/snippets/clients/streamable_basic.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/streamable_basic.py)_ +<!-- /snippet-source --> + +### Client Display Utilities + +When building MCP clients, the SDK provides utilities to help display human-readable names for tools, resources, and prompts: + +<!-- snippet-source examples/snippets/clients/display_utilities.py --> +```python +"""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() +``` + +_Full example: [examples/snippets/clients/display_utilities.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/display_utilities.py)_ +<!-- /snippet-source --> + +The `get_display_name()` function implements the proper precedence rules for displaying names: + +- For tools: `title` > `annotations.title` > `name` +- For other objects: `title` > `name` + +This ensures your client UI shows the most user-friendly names that servers provide. + +### OAuth Authentication for Clients + +The SDK includes [authorization support](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) for connecting to protected MCP servers: + +<!-- snippet-source examples/snippets/clients/oauth_client.py --> +```python +"""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 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() -> tuple[str, str | None]: + callback_url = input("Paste callback URL: ") + params = parse_qs(urlparse(callback_url).query) + return params["code"][0], params.get("state", [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() +``` + +_Full example: [examples/snippets/clients/oauth_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/oauth_client.py)_ +<!-- /snippet-source --> + +For a complete working example, see [`examples/clients/simple-auth-client/`](examples/clients/simple-auth-client/). + +### Parsing Tool Results + +When calling tools through MCP, the `CallToolResult` object contains the tool's response in a structured format. Understanding how to parse this result is essential for properly handling tool outputs. + +```python +"""examples/snippets/clients/parsing_tool_results.py""" + +import asyncio + +from mcp import ClientSession, StdioServerParameters, types +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, "structuredContent") and result.structuredContent: + # Access structured data directly + user_data = result.structuredContent + 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}") + elif isinstance(resource, types.BlobResourceContents): + 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.mimeType}): {len(content.data)} bytes") + + # Example 5: Handling errors + result = await session.call_tool("failing_tool", {}) + if result.isError: + 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()) +``` + +### MCP Primitives + +The MCP protocol defines three core primitives that servers can implement: + +| Primitive | Control | Description | Example Use | +|-----------|-----------------------|-----------------------------------------------------|------------------------------| +| Prompts | User-controlled | Interactive templates invoked by user choice | Slash commands, menu options | +| Resources | Application-controlled| Contextual data managed by the client application | File contents, API responses | +| Tools | Model-controlled | Functions exposed to the LLM to take actions | API calls, data updates | + +### Server Capabilities + +MCP servers declare capabilities during initialization: + +| Capability | Feature Flag | Description | +|--------------|------------------------------|------------------------------------| +| `prompts` | `listChanged` | Prompt template management | +| `resources` | `subscribe`<br/>`listChanged`| Resource exposure and updates | +| `tools` | `listChanged` | Tool discovery and execution | +| `logging` | - | Server logging configuration | +| `completions`| - | Argument completion suggestions | + +## Documentation + +- [API Reference](https://modelcontextprotocol.github.io/python-sdk/api/) +- [Experimental Features (Tasks)](https://modelcontextprotocol.github.io/python-sdk/experimental/tasks/) +- [Model Context Protocol documentation](https://modelcontextprotocol.io) +- [Model Context Protocol specification](https://modelcontextprotocol.io/specification/latest) +- [Officially supported servers](https://github.com/modelcontextprotocol/servers) + +## 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](CONTRIBUTING.md) to get started. + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. diff --git a/SECURITY.md b/SECURITY.md index 654515610..502924200 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,15 +1,21 @@ # Security Policy -Thank you for helping us keep the SDKs and systems they interact with secure. +Thank you for helping keep the Model Context Protocol and its ecosystem secure. ## Reporting Security Issues -This SDK is maintained by [Anthropic](https://www.anthropic.com/) as part of the Model Context Protocol project. +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. -The security of our systems and user data is Anthropic’s top priority. We appreciate the work of security researchers acting in good faith in identifying and reporting potential vulnerabilities. +Please **do not** report security vulnerabilities through public GitHub issues, discussions, +or pull requests. -Our security program is managed on HackerOne and we ask that any validated vulnerability in this functionality be reported through their [submission form](https://hackerone.com/anthropic-vdp/reports/new?type=team&report_type=vulnerability). +## What to Include -## Vulnerability Disclosure Program +To help us triage and respond quickly, please include: -Our Vulnerability Program Guidelines are defined on our [HackerOne program page](https://hackerone.com/anthropic-vdp). +- A description of the vulnerability +- Steps to reproduce the issue +- The potential impact +- Any suggested fixes (optional) diff --git a/docs/experimental/index.md b/docs/experimental/index.md index 1d496b3f1..c97fe2a3d 100644 --- a/docs/experimental/index.md +++ b/docs/experimental/index.md @@ -27,10 +27,9 @@ Tasks are useful for: Experimental features are accessed via the `.experimental` property: ```python -# Server-side -@server.experimental.get_task() -async def handle_get_task(request: GetTaskRequest) -> GetTaskResult: - ... +# Server-side: enable task support (auto-registers default handlers) +server = Server(name="my-server") +server.experimental.enable_tasks() # Client-side result = await session.experimental.call_tool_as_task("tool_name", {"arg": "value"}) diff --git a/docs/experimental/tasks-client.md b/docs/experimental/tasks-client.md index cfd23e4e1..0374ed86b 100644 --- a/docs/experimental/tasks-client.md +++ b/docs/experimental/tasks-client.md @@ -337,7 +337,7 @@ if __name__ == "__main__": Handle task errors gracefully: ```python -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError try: result = await session.experimental.call_tool_as_task("my_tool", args) @@ -349,8 +349,8 @@ try: final = await session.experimental.get_task_result(task_id, CallToolResult) -except McpError as e: - print(f"MCP error: {e.error.message}") +except MCPError as e: + print(f"MCP error: {e.message}") except Exception as e: print(f"Error: {e}") ``` diff --git a/docs/index.md b/docs/index.md index eb5ddf400..e096d910b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,9 +15,9 @@ If you want to read more about the specification, please visit the [MCP document Here's a simple MCP server that exposes a tool, resource, and prompt: ```python title="server.py" -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer -mcp = FastMCP("Test Server", json_response=True) +mcp = MCPServer("Test Server", json_response=True) @mcp.tool() diff --git a/docs/installation.md b/docs/installation.md index 6e20706a8..f39846235 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -21,7 +21,7 @@ The following dependencies are automatically installed: - [`starlette`](https://pypi.org/project/starlette/): Web framework used to build the HTTP transport endpoints. - [`python-multipart`](https://pypi.org/project/python-multipart/): Handle HTTP body parsing. - [`sse-starlette`](https://pypi.org/project/sse-starlette/): Server-Sent Events for Starlette, used to build the SSE transport endpoint. -- [`pydantic-settings`](https://pypi.org/project/pydantic-settings/): Settings management used in FastMCP. +- [`pydantic-settings`](https://pypi.org/project/pydantic-settings/): Settings management used in MCPServer. - [`uvicorn`](https://pypi.org/project/uvicorn/): ASGI server used to run the HTTP transport endpoints. - [`jsonschema`](https://pypi.org/project/jsonschema/): JSON schema validation. - [`pywin32`](https://pypi.org/project/pywin32/): Windows specific dependencies for the CLI tools. diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 000000000..631683693 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,833 @@ +# 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. + +## Overview + +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. + +## Breaking Changes + +### `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, +) + +async with http_client: + async with streamable_http_client( + url="http://localhost:8000/mcp", + http_client=http_client, + ) as (read_stream, write_stream): + ... +``` + +### `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. + +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). + +### Removed type aliases and classes + +The following deprecated 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 | + +**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 +``` + +### `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"}) +``` + +### `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")) +``` + +### `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 +``` + +### `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 + +mcp = MCPServer("Demo") +``` + +### `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. + +### 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 +``` + +**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.progress_token`) → Dictionary access (`meta.get("progress_token")`) +- `progress_token` field changed from `ProgressToken | None = None` to `NotRequired[ProgressToken]` + +**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.progress_token: + await ctx.session.send_progress_notification(ctx.meta.progress_token, 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) +``` + +### `RequestContext` type parameters simplified + +The `RequestContext` class has been split to separate shared fields from server-specific fields. The shared `RequestContext` now only takes 1 type parameter (the session type) instead of 3. + +**`RequestContext` changes:** + +- Type parameters reduced from `RequestContext[SessionT, LifespanContextT, RequestT]` to `RequestContext[SessionT]` +- Server-specific fields (`lifespan_context`, `experimental`, `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] +``` + +### `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:** + +```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 +@server.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, +) +``` + +### 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/draft/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 + +# Required wrapping in AnyUrl +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` +- `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)) +``` + +### 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 — handlers now receive typed params directly rather than a generic request type. + +```python +# Before (v1) +from typing import Any + +from mcp.server.lowlevel.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 exclusively through constructor `on_*` keyword arguments. There is no public API to register handlers after construction. + +```python +# Before (v1) — direct dict access +from mcp.types import ListToolsRequest + +if ListToolsRequest in server.request_handlers: + ... + +# After (v2) — no public access to handler dicts +# Use the on_* constructor params to register handlers +server = Server("my-server", on_list_tools=handle_list_tools) +``` + +### 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 + +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={})]) + + +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`, `lifespan_context`, and `experimental` 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. + +**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) +``` + +### 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 `str` into `TextResourceContents` and `bytes` into `BlobResourceContents` (with base64 encoding), and applied a default mime type of `text/plain`: + +```python +# Before (v1) — str/bytes auto-wrapped with mime type defaulting +@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`: `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 is now an internal implementation detail and should not be relied upon. + +**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`: request-specific fields are now optional + +The `RequestContext` class now uses optional fields for request-specific data (`request_id`, `meta`, etc.) so it can be used for both request and notification handlers. In notification handlers, these fields are `None`. + +```python +from mcp.server import ServerRequestContext + +# request_id, meta, etc. are available in request handlers +# but None in notification handlers +``` + +### Experimental: task handler decorators removed + +The experimental decorator methods on `ExperimentalHandlers` (`@server.experimental.list_tasks()`, `@server.experimental.get_task()`, etc.) have been removed. + +Default task handlers are still registered automatically via `server.experimental.enable_tasks()`. Custom handlers can be passed as `on_*` kwargs to override specific defaults. + +**Before (v1):** + +```python +server = Server("my-server") +server.experimental.enable_tasks() + +@server.experimental.get_task() +async def custom_get_task(request: GetTaskRequest) -> GetTaskResult: + ... +``` + +**After (v2):** + +```python +from mcp.server import Server, ServerRequestContext +from mcp.types import GetTaskRequestParams, GetTaskResult + + +async def custom_get_task(ctx: ServerRequestContext, params: GetTaskRequestParams) -> GetTaskResult: + ... + + +server = Server("my-server") +server.experimental.enable_tasks(on_get_task=custom_get_task) +``` + +## Deprecations + +<!-- Add deprecations below --> + +## Bug Fixes + +### 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. + +### Extra fields no longer allowed on top-level MCP types + +MCP protocol types no longer accept arbitrary extra fields at the top level. This matches the MCP specification which only allows extra fields within `_meta` objects, not on the types themselves. + +```python +# This will now raise a validation error +from mcp.types import CallToolRequestParams + +params = CallToolRequestParams( + name="my_tool", + arguments={}, + unknown_field="value", # ValidationError: extra fields not permitted +) + +# Extra fields are still allowed in _meta +params = CallToolRequestParams( + name="my_tool", + arguments={}, + _meta={"progressToken": "tok", "customField": "value"}, # OK +) +``` + +## New Features + +### `streamable_http_app()` available on lowlevel Server + +The `streamable_http_app()` method is now available directly on the lowlevel `Server` class, not just `MCPServer`. This allows using the streamable HTTP transport without the MCPServer wrapper. + +```python +from mcp.server import Server, ServerRequestContext +from mcp.types import ListToolsResult, PaginatedRequestParams + + +async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[...]) + + +server = Server("my-server", on_list_tools=handle_list_tools) + +app = server.streamable_http_app( + streamable_http_path="/mcp", + json_response=False, + stateless_http=False, +) +``` + +The lowlevel `Server` also now exposes a `session_manager` property to access the `StreamableHTTPSessionManager` after calling `streamable_http_app()`. + +## Need Help? + +If you encounter issues during migration: + +1. Check the [API Reference](api.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/testing.md b/docs/testing.md index 8d8444989..9a222c906 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,15 +1,16 @@ # Testing MCP Servers -If you call yourself a developer, you will want to test your MCP server. -The Python SDK offers the `create_connected_server_and_client_session` function to create a session -using an in-memory transport. I know, I know, the name is too long... We are working on improving it. +The Python SDK provides a `Client` class for testing MCP servers with an in-memory transport. +This makes it easy to write tests without network overhead. -Anyway, let's assume you have a simple server with a single tool: +## Basic Usage + +Let's assume you have a simple server with a single tool: ```python title="server.py" -from mcp.server import FastMCP +from mcp.server import MCPServer -app = FastMCP("Calculator") +app = MCPServer("Calculator") @app.tool() def add(a: int, b: int) -> int: @@ -40,12 +41,9 @@ To run the below test, you'll need to install the following dependencies: server - you don't need to use it, but we are spreading the word for best practices. ```python title="test_server.py" -from collections.abc import AsyncGenerator - import pytest from inline_snapshot import snapshot -from mcp.client.session import ClientSession -from mcp.shared.memory import create_connected_server_and_client_session +from mcp import Client from mcp.types import CallToolResult, TextContent from server import app @@ -57,14 +55,14 @@ def anyio_backend(): # (1)! @pytest.fixture -async def client_session() -> AsyncGenerator[ClientSession]: - async with create_connected_server_and_client_session(app, raise_exceptions=True) as _session: - yield _session +async def client(): # (2)! + async with Client(app, raise_exceptions=True) as c: + yield c @pytest.mark.anyio -async def test_call_add_tool(client_session: ClientSession): - result = await client_session.call_tool("add", {"a": 1, "b": 2}) +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")], @@ -74,5 +72,6 @@ async def test_call_add_tool(client_session: ClientSession): ``` 1. If you are using `trio`, you should set `"trio"` as the `anyio_backend`. Check more information in the [anyio documentation](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on). +2. The `client` fixture creates a connected client that can be reused across multiple tests. There you go! You can now extend your tests to cover more scenarios. diff --git a/examples/clients/conformance-auth-client/README.md b/examples/clients/conformance-auth-client/README.md deleted file mode 100644 index 312a992d0..000000000 --- a/examples/clients/conformance-auth-client/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# MCP Conformance Auth Client - -A Python OAuth client designed for use with the MCP conformance test framework. - -## Overview - -This client implements OAuth authentication for MCP and is designed to work automatically with the conformance test framework without requiring user interaction. It programmatically fetches authorization URLs and extracts auth codes from redirects. - -## Installation - -```bash -cd examples/clients/conformance-auth-client -uv sync -``` - -## Usage with Conformance Tests - -Run the auth conformance tests against this Python client: - -```bash -# From the conformance repository -npx @modelcontextprotocol/conformance client \ - --command "uv run --directory /path/to/python-sdk/examples/clients/conformance-auth-client python -m mcp_conformance_auth_client" \ - --scenario auth/basic-dcr -``` - -Available auth test scenarios: - -- `auth/basic-dcr` - Tests OAuth Dynamic Client Registration flow -- `auth/basic-metadata-var1` - Tests OAuth with authorization metadata - -## How It Works - -Unlike interactive OAuth clients that open a browser for user authentication, this client: - -1. Receives the authorization URL from the OAuth provider -2. Makes an HTTP request to that URL directly (without following redirects) -3. Extracts the authorization code from the redirect response -4. Uses the code to complete the OAuth token exchange - -This allows the conformance test framework's mock OAuth server to automatically provide auth codes without human interaction. - -## Direct Usage - -You can also run the client directly: - -```bash -uv run python -m mcp_conformance_auth_client http://localhost:3000/mcp -``` diff --git a/examples/clients/conformance-auth-client/mcp_conformance_auth_client/__init__.py b/examples/clients/conformance-auth-client/mcp_conformance_auth_client/__init__.py deleted file mode 100644 index eecd92409..000000000 --- a/examples/clients/conformance-auth-client/mcp_conformance_auth_client/__init__.py +++ /dev/null @@ -1,316 +0,0 @@ -#!/usr/bin/env python3 -""" -MCP OAuth conformance test client. - -This client is designed to work with the MCP conformance test framework. -It automatically handles OAuth flows without user interaction by programmatically -fetching the authorization URL and extracting the auth code from the redirect. - -Usage: - python -m mcp_conformance_auth_client <scenario> <server-url> - -Environment Variables: - MCP_CONFORMANCE_CONTEXT - JSON object containing test credentials: - { - "client_id": "...", - "client_secret": "...", # For client_secret_basic flow - "private_key_pem": "...", # For private_key_jwt flow - "signing_algorithm": "ES256" # Optional, defaults to ES256 - } - -Scenarios: - auth/* - Authorization code flow scenarios (default behavior) - auth/client-credentials-jwt - Client credentials with JWT authentication (SEP-1046) - auth/client-credentials-basic - Client credentials with client_secret_basic -""" - -import asyncio -import json -import logging -import os -import sys -from datetime import timedelta -from urllib.parse import ParseResult, parse_qs, urlparse - -import httpx -from mcp import ClientSession -from mcp.client.auth import OAuthClientProvider, TokenStorage -from mcp.client.auth.extensions.client_credentials import ( - ClientCredentialsOAuthProvider, - PrivateKeyJWTOAuthProvider, - SignedJWTParameters, -) -from mcp.client.streamable_http import streamablehttp_client -from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken -from pydantic import AnyUrl - - -def get_conformance_context() -> dict: - """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 - - -# 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__) - - -class InMemoryTokenStorage(TokenStorage): - """Simple in-memory token storage for conformance testing.""" - - 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 ConformanceOAuthCallbackHandler: - """ - OAuth callback handler that automatically fetches the authorization URL - and extracts the auth code, without requiring user interaction. - - This mimics the behavior of the TypeScript ConformanceOAuthProvider. - """ - - def __init__(self): - self._auth_code: str | None = None - self._state: str | None = None - - async def handle_redirect(self, authorization_url: str) -> None: - """ - Fetch the authorization URL and extract the auth code from the redirect. - - The conformance test server returns a redirect with the auth code, - so we can capture it programmatically. - """ - logger.debug(f"Fetching authorization URL: {authorization_url}") - - async with httpx.AsyncClient() as client: - response = await client.get( - authorization_url, - follow_redirects=False, # Don't follow redirects automatically - ) - - # Check for redirect response - if response.status_code in (301, 302, 303, 307, 308): - location = response.headers.get("location") - if location: - redirect_url: ParseResult = 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 - 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) -> tuple[str, str | None]: - """Return the captured auth code and state, then clear them for potential reuse.""" - if self._auth_code is None: - raise RuntimeError("No authorization code available - was handle_redirect called?") - auth_code = self._auth_code - state = self._state - # Clear the stored values so the next auth flow gets fresh ones - self._auth_code = None - self._state = None - return auth_code, state - - -async def run_authorization_code_client(server_url: str) -> None: - """ - Run the conformance test client with authorization code flow. - - This function: - 1. Connects to the MCP server with OAuth authorization code flow - 2. Initializes the session - 3. Lists available tools - 4. Calls a test tool - """ - logger.debug(f"Starting conformance auth client (authorization_code) for {server_url}") - - # Create callback handler that will automatically fetch auth codes - callback_handler = ConformanceOAuthCallbackHandler() - - # Create OAuth authentication handler - oauth_auth = OAuthClientProvider( - server_url=server_url, - client_metadata=OAuthClientMetadata( - client_name="conformance-auth-client", - redirect_uris=[AnyUrl("http://localhost:3000/callback")], - grant_types=["authorization_code", "refresh_token"], - response_types=["code"], - ), - storage=InMemoryTokenStorage(), - redirect_handler=callback_handler.handle_redirect, - callback_handler=callback_handler.handle_callback, - ) - - await _run_session(server_url, oauth_auth) - - -async def run_client_credentials_jwt_client(server_url: str) -> None: - """ - Run the conformance test client with client credentials flow using private_key_jwt (SEP-1046). - - This function: - 1. Connects to the MCP server with OAuth client_credentials grant - 2. Uses private_key_jwt authentication with credentials from MCP_CONFORMANCE_CONTEXT - 3. Initializes the session - 4. Lists available tools - 5. Calls a test tool - """ - logger.debug(f"Starting conformance auth client (client_credentials_jwt) for {server_url}") - - # Load credentials from environment - 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'") - - # Create JWT parameters for SDK-signed assertions - jwt_params = SignedJWTParameters( - issuer=client_id, - subject=client_id, - signing_algorithm=signing_algorithm, - signing_key=private_key_pem, - ) - - # Create OAuth provider for client_credentials with private_key_jwt - oauth_auth = PrivateKeyJWTOAuthProvider( - server_url=server_url, - storage=InMemoryTokenStorage(), - client_id=client_id, - assertion_provider=jwt_params.create_assertion_provider(), - ) - - await _run_session(server_url, oauth_auth) - - -async def run_client_credentials_basic_client(server_url: str) -> None: - """ - Run the conformance test client with client credentials flow using client_secret_basic. - - This function: - 1. Connects to the MCP server with OAuth client_credentials grant - 2. Uses client_secret_basic authentication with credentials from MCP_CONFORMANCE_CONTEXT - 3. Initializes the session - 4. Lists available tools - 5. Calls a test tool - """ - logger.debug(f"Starting conformance auth client (client_credentials_basic) for {server_url}") - - # Load credentials from environment - 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'") - - # Create OAuth provider for client_credentials with client_secret_basic - 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_session(server_url, oauth_auth) - - -async def _run_session(server_url: str, oauth_auth: OAuthClientProvider) -> None: - """Common session logic for all OAuth flows.""" - # Connect using streamable HTTP transport with OAuth - async with streamablehttp_client( - url=server_url, - auth=oauth_auth, - timeout=timedelta(seconds=30), - sse_read_timeout=timedelta(seconds=60), - ) as (read_stream, write_stream, _): - async with ClientSession(read_stream, write_stream) as session: - # Initialize the session - await session.initialize() - logger.debug("Successfully connected and initialized MCP session") - - # List tools - tools_result = await session.list_tools() - logger.debug(f"Listed tools: {[t.name for t in tools_result.tools]}") - - # Call test tool (expected by conformance tests) - try: - result = await session.call_tool("test-tool", {}) - logger.debug(f"Called test-tool, 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 auth client.""" - if len(sys.argv) != 3: - print(f"Usage: {sys.argv[0]} <scenario> <server-url>", file=sys.stderr) - print("", file=sys.stderr) - print("Scenarios:", file=sys.stderr) - print(" auth/* - Authorization code flow (default)", file=sys.stderr) - print(" auth/client-credentials-jwt - Client credentials with JWT auth (SEP-1046)", file=sys.stderr) - print(" auth/client-credentials-basic - Client credentials with client_secret_basic", file=sys.stderr) - sys.exit(1) - - scenario = sys.argv[1] - server_url = sys.argv[2] - - try: - if scenario == "auth/client-credentials-jwt": - asyncio.run(run_client_credentials_jwt_client(server_url)) - elif scenario == "auth/client-credentials-basic": - asyncio.run(run_client_credentials_basic_client(server_url)) - else: - # Default to authorization code flow for all other auth/* scenarios - asyncio.run(run_authorization_code_client(server_url)) - except Exception: - logger.exception("Client failed") - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/examples/clients/conformance-auth-client/mcp_conformance_auth_client/__main__.py b/examples/clients/conformance-auth-client/mcp_conformance_auth_client/__main__.py deleted file mode 100644 index 1b8f8acb0..000000000 --- a/examples/clients/conformance-auth-client/mcp_conformance_auth_client/__main__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Allow running the module with python -m.""" - -from . import main - -if __name__ == "__main__": - main() diff --git a/examples/clients/conformance-auth-client/pyproject.toml b/examples/clients/conformance-auth-client/pyproject.toml deleted file mode 100644 index 3d03b4d4a..000000000 --- a/examples/clients/conformance-auth-client/pyproject.toml +++ /dev/null @@ -1,43 +0,0 @@ -[project] -name = "mcp-conformance-auth-client" -version = "0.1.0" -description = "OAuth conformance test client for MCP" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Anthropic" }] -keywords = ["mcp", "oauth", "client", "auth", "conformance", "testing"] -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 = ["mcp", "httpx>=0.28.1"] - -[project.scripts] -mcp-conformance-auth-client = "mcp_conformance_auth_client:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_conformance_auth_client"] - -[tool.pyright] -include = ["mcp_conformance_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-auth-client/README.md b/examples/clients/simple-auth-client/README.md index 3e92f2947..708c0371b 100644 --- a/examples/clients/simple-auth-client/README.md +++ b/examples/clients/simple-auth-client/README.md @@ -12,29 +12,48 @@ A demonstration of how to use the MCP Python SDK with OAuth authentication over ```bash cd examples/clients/simple-auth-client -uv sync --reinstall +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 -# Example with mcp-simple-auth -cd path/to/mcp-simple-auth -uv run mcp-simple-auth --transport streamable-http --port 3001 +# 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 -uv run mcp-simple-auth-client +# Connect to Resource Server (new architecture, default port 8001) +MCP_SERVER_PORT=8001 uv run mcp-simple-auth-client -# Or with custom server URL -MCP_SERVER_PORT=3001 uv run mcp-simple-auth-client +# Connect to Legacy Server (port 8000) +uv run mcp-simple-auth-client # Use SSE transport -MCP_TRANSPORT_TYPE=sse uv run mcp-simple-auth-client +MCP_SERVER_PORT=8001 MCP_TRANSPORT_TYPE=sse uv run mcp-simple-auth-client ``` ### 3. Complete OAuth flow @@ -42,33 +61,38 @@ MCP_TRANSPORT_TYPE=sse uv run mcp-simple-auth-client The client will open your browser for authentication. After completing OAuth, you can use commands: - `list` - List available tools -- `call <tool_name> [args]` - Call a tool with optional JSON arguments +- `call <tool_name> [args]` - Call a tool with optional JSON arguments - `quit` - Exit ## Example ```markdown -🔐 Simple MCP Auth Client -Connecting to: http://localhost:3001 +🚀 Simple MCP Auth Client +Connecting to: http://localhost:8001/mcp +Transport type: streamable-http -Please visit the following URL to authorize the application: -http://localhost:3001/authorize?response_type=code&client_id=... +🔗 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:3001 +✅ Connected to MCP server at http://localhost:8001/mcp mcp> list 📋 Available tools: -1. echo - Echo back the input text +1. get_time + Description: Get the current server time. -mcp> call echo {"text": "Hello, world!"} -🔧 Tool 'echo' result: -Hello, world! +mcp> call get_time +🔧 Tool 'get_time' result: +{"current_time": "2024-01-15T10:30:00", "timezone": "UTC", ...} mcp> quit -👋 Goodbye! ``` ## Configuration -- `MCP_SERVER_PORT` - Server URL (default: 8000) -- `MCP_TRANSPORT_TYPE` - Transport type: `streamable-http` (default) or `sse` +| 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/main.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py index 38dc5a916..5fac56be5 100644 --- a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py @@ -1,26 +1,30 @@ #!/usr/bin/env python3 -""" -Simple MCP client example with OAuth authentication support. +"""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 datetime import timedelta from http.server import BaseHTTPRequestHandler, HTTPServer from typing import Any from urllib.parse import parse_qs, urlparse +import httpx +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from mcp.client.auth import OAuthClientProvider, TokenStorage from mcp.client.session import ClientSession from mcp.client.sse import sse_client -from mcp.client.streamable_http import streamablehttp_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): @@ -46,7 +50,13 @@ async def set_client_info(self, client_info: OAuthClientInformationFull) -> None class CallbackHandler(BaseHTTPRequestHandler): """Simple HTTP handler to capture OAuth callback.""" - def __init__(self, request, client_address, server, callback_data): + 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) @@ -91,15 +101,14 @@ def do_GET(self): self.send_response(404) self.end_headers() - def log_message(self, format, *args): + def log_message(self, format: str, *args: Any): """Suppress default logging.""" - pass class CallbackServer: """Simple server to handle OAuth callbacks.""" - def __init__(self, port=3000): + def __init__(self, port: int = 3000): self.port = port self.server = None self.thread = None @@ -110,7 +119,12 @@ def _create_handler_with_data(self): callback_data = self.callback_data class DataCallbackHandler(CallbackHandler): - def __init__(self, request, client_address, server): + def __init__( + self, + request: BaseHTTPRequestHandler, + client_address: tuple[str, int], + server: socketserver.BaseServer, + ): super().__init__(request, client_address, server, callback_data) return DataCallbackHandler @@ -131,7 +145,7 @@ def stop(self): if self.thread: self.thread.join(timeout=1) - def wait_for_callback(self, timeout=300): + def wait_for_callback(self, timeout: int = 300): """Wait for OAuth callback with timeout.""" start_time = time.time() while time.time() - start_time < timeout: @@ -193,7 +207,7 @@ async def _default_redirect_handler(authorization_url: str) -> None: # 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, + server_url=self.server_url.replace("/mcp", ""), client_metadata=OAuthClientMetadata.model_validate(client_metadata_dict), storage=InMemoryTokenStorage(), redirect_handler=_default_redirect_handler, @@ -207,17 +221,17 @@ async def _default_redirect_handler(authorization_url: str) -> None: async with sse_client( url=self.server_url, auth=oauth_auth, - timeout=60, + timeout=60.0, ) as (read_stream, write_stream): - await self._run_session(read_stream, write_stream, None) + await self._run_session(read_stream, write_stream) else: print("📡 Opening StreamableHTTP transport connection with auth...") - async with streamablehttp_client( - url=self.server_url, - auth=oauth_auth, - timeout=timedelta(seconds=60), - ) as (read_stream, write_stream, get_session_id): - await self._run_session(read_stream, write_stream, get_session_id) + 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}") @@ -225,7 +239,11 @@ async def _default_redirect_handler(authorization_url: str) -> None: traceback.print_exc() - async def _run_session(self, read_stream, write_stream, get_session_id): + async def _run_session( + self, + read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], + write_stream: MemoryObjectSendStream[SessionMessage], + ): """Run the MCP session with the given streams.""" print("🤝 Initializing MCP session...") async with ClientSession(read_stream, write_stream) as session: @@ -235,10 +253,6 @@ async def _run_session(self, read_stream, write_stream, get_session_id): print("✨ Session initialization complete!") print(f"\n✅ Connected to MCP server at {self.server_url}") - if get_session_id: - session_id = get_session_id() - if session_id: - print(f"Session ID: {session_id}") # Run interactive loop await self.interactive_loop() @@ -314,7 +328,7 @@ async def interactive_loop(self): continue # Parse arguments (simple JSON-like format) - arguments = {} + arguments: dict[str, Any] = {} if len(parts) > 2: import json diff --git a/examples/clients/simple-auth-client/pyproject.toml b/examples/clients/simple-auth-client/pyproject.toml index 46aba8dc1..f84d1430f 100644 --- a/examples/clients/simple-auth-client/pyproject.toml +++ b/examples/clients/simple-auth-client/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "A simple OAuth client for the MCP simple-auth server" readme = "README.md" requires-python = ">=3.10" -authors = [{ name = "Anthropic" }] +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] keywords = ["mcp", "oauth", "client", "auth"] license = { text = "MIT" } classifiers = [ diff --git a/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py b/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py index 78a81a4d9..72b1a6f20 100644 --- a/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py +++ b/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import json import logging @@ -93,7 +95,7 @@ async def initialize(self) -> None: await self.cleanup() raise - async def list_tools(self) -> list[Any]: + async def list_tools(self) -> list[Tool]: """List available tools from the server. Returns: @@ -106,11 +108,11 @@ async def list_tools(self) -> list[Any]: raise RuntimeError(f"Server {self.name} not initialized") tools_response = await self.session.list_tools() - tools = [] + tools: list[Tool] = [] for item in tools_response: - if isinstance(item, tuple) and item[0] == "tools": - tools.extend(Tool(tool.name, tool.description, tool.inputSchema, tool.title) for tool in item[1]) + if item[0] == "tools": + tools.extend(Tool(tool.name, tool.description, tool.input_schema, tool.title) for tool in item[1]) return tools @@ -189,7 +191,7 @@ def format_for_llm(self) -> str: Returns: A formatted string describing the tool. """ - args_desc = [] + 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')}" @@ -311,9 +313,9 @@ def _clean_json_string(json_string: str) -> str: result = await server.execute_tool(tool_call["tool"], tool_call["arguments"]) if isinstance(result, dict) and "progress" in result: - progress = result["progress"] - total = result["total"] - percentage = (progress / total) * 100 + 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}" @@ -338,7 +340,7 @@ async def start(self) -> None: await self.cleanup_servers() return - all_tools = [] + all_tools: list[Tool] = [] for server in self.servers: tools = await server.list_tools() all_tools.extend(tools) diff --git a/examples/clients/simple-chatbot/pyproject.toml b/examples/clients/simple-chatbot/pyproject.toml index 564b42df3..2d7205735 100644 --- a/examples/clients/simple-chatbot/pyproject.toml +++ b/examples/clients/simple-chatbot/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "A simple CLI chatbot using the Model Context Protocol (MCP)" readme = "README.md" requires-python = ">=3.10" -authors = [{ name = "Edoardo Cilia" }] +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] keywords = ["mcp", "llm", "chatbot", "cli"] license = { text = "MIT" } classifiers = [ @@ -16,7 +16,6 @@ classifiers = [ ] dependencies = [ "python-dotenv>=1.0.0", - "requests>=2.31.0", "mcp", "uvicorn>=0.32.1", ] diff --git a/examples/clients/simple-task-client/mcp_simple_task_client/main.py b/examples/clients/simple-task-client/mcp_simple_task_client/main.py index 12691162a..f9e555c8e 100644 --- a/examples/clients/simple-task-client/mcp_simple_task_client/main.py +++ b/examples/clients/simple-task-client/mcp_simple_task_client/main.py @@ -4,12 +4,12 @@ import click from mcp import ClientSession -from mcp.client.streamable_http import streamablehttp_client +from mcp.client.streamable_http import streamable_http_client from mcp.types import CallToolResult, TextContent async def run(url: str) -> None: - async with streamablehttp_client(url) as (read, write, _): + async with streamable_http_client(url) as (read, write): async with ClientSession(read, write) as session: await session.initialize() @@ -25,15 +25,16 @@ async def run(url: str) -> None: arguments={}, ttl=60000, ) - task_id = result.task.taskId + task_id = result.task.task_id print(f"Task created: {task_id}") + status = None # Poll until done (respects server's pollInterval hint) async for status in session.experimental.poll_task(task_id): - print(f" Status: {status.status} - {status.statusMessage or ''}") + print(f" Status: {status.status} - {status.status_message or ''}") # Check final status - if status.status != "completed": + if status and status.status != "completed": print(f"Task ended with status: {status.status}") return diff --git a/examples/clients/simple-task-client/pyproject.toml b/examples/clients/simple-task-client/pyproject.toml index da10392e3..c7abf5115 100644 --- a/examples/clients/simple-task-client/pyproject.toml +++ b/examples/clients/simple-task-client/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "A simple MCP client demonstrating task polling" readme = "README.md" requires-python = ">=3.10" -authors = [{ name = "Anthropic, PBC." }] +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] keywords = ["mcp", "llm", "tasks", "client"] license = { text = "MIT" } classifiers = [ diff --git a/examples/clients/simple-task-interactive-client/README.md b/examples/clients/simple-task-interactive-client/README.md index ac73d2bc1..3397d3b5d 100644 --- a/examples/clients/simple-task-interactive-client/README.md +++ b/examples/clients/simple-task-interactive-client/README.md @@ -49,7 +49,7 @@ async def sampling_callback(context, params) -> CreateMessageResult: ```python # Call a tool as a task (returns immediately with task reference) result = await session.experimental.call_tool_as_task("tool_name", {"arg": "value"}) -task_id = result.task.taskId +task_id = result.task.task_id # Get result - this delivers elicitation/sampling requests and blocks until complete final = await session.experimental.get_task_result(task_id, CallToolResult) diff --git a/examples/clients/simple-task-interactive-client/mcp_simple_task_interactive_client/main.py b/examples/clients/simple-task-interactive-client/mcp_simple_task_interactive_client/main.py index a8a47dc57..ff5f49928 100644 --- a/examples/clients/simple-task-interactive-client/mcp_simple_task_interactive_client/main.py +++ b/examples/clients/simple-task-interactive-client/mcp_simple_task_interactive_client/main.py @@ -7,12 +7,11 @@ """ import asyncio -from typing import Any import click from mcp import ClientSession -from mcp.client.streamable_http import streamablehttp_client -from mcp.shared.context import RequestContext +from mcp.client.context import ClientRequestContext +from mcp.client.streamable_http import streamable_http_client from mcp.types import ( CallToolResult, CreateMessageRequestParams, @@ -24,7 +23,7 @@ async def elicitation_callback( - context: RequestContext[ClientSession, Any], + context: ClientRequestContext, params: ElicitRequestParams, ) -> ElicitResult: """Handle elicitation requests from the server.""" @@ -39,7 +38,7 @@ async def elicitation_callback( async def sampling_callback( - context: RequestContext[ClientSession, Any], + context: ClientRequestContext, params: CreateMessageRequestParams, ) -> CreateMessageResult: """Handle sampling requests from the server.""" @@ -73,7 +72,7 @@ def get_text(result: CallToolResult) -> str: async def run(url: str) -> None: - async with streamablehttp_client(url) as (read, write, _): + async with streamable_http_client(url) as (read, write): async with ClientSession( read, write, @@ -91,7 +90,7 @@ async def run(url: str) -> None: print("Calling confirm_delete tool...") elicit_task = await session.experimental.call_tool_as_task("confirm_delete", {"filename": "important.txt"}) - elicit_task_id = elicit_task.task.taskId + elicit_task_id = elicit_task.task.task_id print(f"Task created: {elicit_task_id}") # Poll until terminal, calling tasks/result on input_required @@ -112,7 +111,7 @@ async def run(url: str) -> None: print("Calling write_haiku tool...") sampling_task = await session.experimental.call_tool_as_task("write_haiku", {"topic": "autumn leaves"}) - sampling_task_id = sampling_task.task.taskId + sampling_task_id = sampling_task.task.task_id print(f"Task created: {sampling_task_id}") # Poll until terminal, calling tasks/result on input_required diff --git a/examples/clients/simple-task-interactive-client/pyproject.toml b/examples/clients/simple-task-interactive-client/pyproject.toml index 224bbc591..47191573f 100644 --- a/examples/clients/simple-task-interactive-client/pyproject.toml +++ b/examples/clients/simple-task-interactive-client/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "A simple MCP client demonstrating interactive task responses" readme = "README.md" requires-python = ">=3.10" -authors = [{ name = "Anthropic, PBC." }] +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] keywords = ["mcp", "llm", "tasks", "client", "elicitation", "sampling"] license = { text = "MIT" } classifiers = [ 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 index 1defd8eaa..e91ed9d52 100644 --- a/examples/clients/sse-polling-client/mcp_sse_polling_client/main.py +++ b/examples/clients/sse-polling-client/mcp_sse_polling_client/main.py @@ -1,5 +1,4 @@ -""" -SSE Polling Demo Client +"""SSE Polling Demo Client Demonstrates the client-side auto-reconnect for SSE polling pattern. @@ -20,9 +19,7 @@ import click from mcp import ClientSession -from mcp.client.streamable_http import streamablehttp_client - -logger = logging.getLogger(__name__) +from mcp.client.streamable_http import streamable_http_client async def run_demo(url: str, items: int, checkpoint_every: int) -> None: @@ -34,7 +31,7 @@ async def run_demo(url: str, items: int, checkpoint_every: int) -> None: print(f"Processing {items} items with checkpoints every {checkpoint_every}") print(f"{'=' * 60}\n") - async with streamablehttp_client(url) as (read_stream, write_stream, _): + 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...") diff --git a/examples/clients/sse-polling-client/pyproject.toml b/examples/clients/sse-polling-client/pyproject.toml index ae896708d..4db29857f 100644 --- a/examples/clients/sse-polling-client/pyproject.toml +++ b/examples/clients/sse-polling-client/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "Demo client for SSE polling with auto-reconnect" readme = "README.md" requires-python = ">=3.10" -authors = [{ name = "Anthropic, PBC." }] +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"] diff --git a/examples/fastmcp/complex_inputs.py b/examples/mcpserver/complex_inputs.py similarity index 84% rename from examples/fastmcp/complex_inputs.py rename to examples/mcpserver/complex_inputs.py index e859165a9..93a42d1c8 100644 --- a/examples/fastmcp/complex_inputs.py +++ b/examples/mcpserver/complex_inputs.py @@ -1,5 +1,4 @@ -""" -FastMCP Complex inputs Example +"""MCPServer Complex inputs Example Demonstrates validation via pydantic with complex models. """ @@ -8,9 +7,9 @@ from pydantic import BaseModel, Field -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer -mcp = FastMCP("Shrimp Tank") +mcp = MCPServer("Shrimp Tank") class ShrimpTank(BaseModel): diff --git a/examples/fastmcp/desktop.py b/examples/mcpserver/desktop.py similarity index 80% rename from examples/fastmcp/desktop.py rename to examples/mcpserver/desktop.py index add7f515b..804184516 100644 --- a/examples/fastmcp/desktop.py +++ b/examples/mcpserver/desktop.py @@ -1,15 +1,14 @@ -""" -FastMCP Desktop Example +"""MCPServer Desktop Example A simple example that exposes the desktop directory as a resource. """ from pathlib import Path -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer # Create server -mcp = FastMCP("Demo") +mcp = MCPServer("Demo") @mcp.resource("dir://desktop") diff --git a/examples/fastmcp/direct_call_tool_result_return.py b/examples/mcpserver/direct_call_tool_result_return.py similarity index 67% rename from examples/fastmcp/direct_call_tool_result_return.py rename to examples/mcpserver/direct_call_tool_result_return.py index a441769b2..44a316bc6 100644 --- a/examples/fastmcp/direct_call_tool_result_return.py +++ b/examples/mcpserver/direct_call_tool_result_return.py @@ -1,15 +1,13 @@ -""" -FastMCP Echo Server with direct CallToolResult return -""" +"""MCPServer Echo Server with direct CallToolResult return""" from typing import Annotated from pydantic import BaseModel -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from mcp.types import CallToolResult, TextContent -mcp = FastMCP("Echo Server") +mcp = MCPServer("Echo Server") class EchoResponse(BaseModel): @@ -20,5 +18,5 @@ class EchoResponse(BaseModel): def echo(text: str) -> Annotated[CallToolResult, EchoResponse]: """Echo the input text with structure and metadata""" return CallToolResult( - content=[TextContent(type="text", text=text)], structuredContent={"text": text}, _meta={"some": "metadata"} + content=[TextContent(type="text", text=text)], structured_content={"text": text}, _meta={"some": "metadata"} ) diff --git a/examples/fastmcp/echo.py b/examples/mcpserver/echo.py similarity index 79% rename from examples/fastmcp/echo.py rename to examples/mcpserver/echo.py index 7bdbcdce6..501c47069 100644 --- a/examples/fastmcp/echo.py +++ b/examples/mcpserver/echo.py @@ -1,11 +1,9 @@ -""" -FastMCP Echo Server -""" +"""MCPServer Echo Server""" -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer # Create server -mcp = FastMCP("Echo Server") +mcp = MCPServer("Echo Server") @mcp.tool() diff --git a/examples/fastmcp/icons_demo.py b/examples/mcpserver/icons_demo.py similarity index 67% rename from examples/fastmcp/icons_demo.py rename to examples/mcpserver/icons_demo.py index c6cf48acd..f50389f32 100644 --- a/examples/fastmcp/icons_demo.py +++ b/examples/mcpserver/icons_demo.py @@ -1,5 +1,4 @@ -""" -FastMCP Icons Demo Server +"""MCPServer Icons Demo Server Demonstrates using icons with tools, resources, prompts, and implementation. """ @@ -7,17 +6,19 @@ import base64 from pathlib import Path -from mcp.server.fastmcp import FastMCP, Icon +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, mimeType="image/png", sizes=["64x64"]) +icon_data = Icon(src=icon_data_uri, mime_type="image/png", sizes=["64x64"]) # Create server with icons in implementation -mcp = FastMCP("Icons Demo Server", website_url="https://github.com/modelcontextprotocol/python-sdk", icons=[icon_data]) +mcp = MCPServer( + "Icons Demo Server", website_url="https://github.com/modelcontextprotocol/python-sdk", icons=[icon_data] +) @mcp.tool(icons=[icon_data]) @@ -40,9 +41,9 @@ def prompt_with_icon(text: str) -> str: @mcp.tool( icons=[ - Icon(src=icon_data_uri, mimeType="image/png", sizes=["16x16"]), - Icon(src=icon_data_uri, mimeType="image/png", sizes=["32x32"]), - Icon(src=icon_data_uri, mimeType="image/png", sizes=["64x64"]), + 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: diff --git a/examples/fastmcp/logging_and_progress.py b/examples/mcpserver/logging_and_progress.py similarity index 80% rename from examples/fastmcp/logging_and_progress.py rename to examples/mcpserver/logging_and_progress.py index 91c2b806d..b157f9dd0 100644 --- a/examples/fastmcp/logging_and_progress.py +++ b/examples/mcpserver/logging_and_progress.py @@ -1,13 +1,11 @@ -""" -FastMCP Echo Server that sends log messages and progress updates to the client -""" +"""MCPServer Echo Server that sends log messages and progress updates to the client""" import asyncio -from mcp.server.fastmcp import Context, FastMCP +from mcp.server.mcpserver import Context, MCPServer # Create server -mcp = FastMCP("Echo Server with logging and progress updates") +mcp = MCPServer("Echo Server with logging and progress updates") @mcp.tool() diff --git a/examples/fastmcp/mcp.png b/examples/mcpserver/mcp.png similarity index 100% rename from examples/fastmcp/mcp.png rename to examples/mcpserver/mcp.png diff --git a/examples/fastmcp/memory.py b/examples/mcpserver/memory.py similarity index 96% rename from examples/fastmcp/memory.py rename to examples/mcpserver/memory.py index 35094ec9c..fd0bd9362 100644 --- a/examples/fastmcp/memory.py +++ b/examples/mcpserver/memory.py @@ -4,8 +4,7 @@ # uv pip install 'pydantic-ai-slim[openai]' asyncpg numpy pgvector -""" -Recursive memory system inspired by the human brain's clustering of memories. +"""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. """ @@ -25,7 +24,7 @@ from pydantic import BaseModel, Field from pydantic_ai import Agent -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer MAX_DEPTH = 5 SIMILARITY_THRESHOLD = 0.7 @@ -37,19 +36,11 @@ T = TypeVar("T") -mcp = FastMCP( - "memory", - dependencies=[ - "pydantic-ai-slim[openai]", - "asyncpg", - "numpy", - "pgvector", - ], -) +mcp = MCPServer("memory") DB_DSN = "postgresql://postgres:postgres@localhost:54320/memory_db" -# reset memory with rm ~/.fastmcp/{USER}/memory/* -PROFILE_DIR = (Path.home() / ".fastmcp" / os.environ.get("USER", "anon") / "memory").resolve() +# 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) diff --git a/examples/fastmcp/parameter_descriptions.py b/examples/mcpserver/parameter_descriptions.py similarity index 76% rename from examples/fastmcp/parameter_descriptions.py rename to examples/mcpserver/parameter_descriptions.py index dc56e9182..59a1caf3f 100644 --- a/examples/fastmcp/parameter_descriptions.py +++ b/examples/mcpserver/parameter_descriptions.py @@ -1,13 +1,11 @@ -""" -FastMCP Example showing parameter descriptions -""" +"""MCPServer Example showing parameter descriptions""" from pydantic import Field -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer # Create server -mcp = FastMCP("Parameter Descriptions Server") +mcp = MCPServer("Parameter Descriptions Server") @mcp.tool() diff --git a/examples/fastmcp/readme-quickstart.py b/examples/mcpserver/readme-quickstart.py similarity index 82% rename from examples/fastmcp/readme-quickstart.py rename to examples/mcpserver/readme-quickstart.py index e1abf7c51..864b774a9 100644 --- a/examples/fastmcp/readme-quickstart.py +++ b/examples/mcpserver/readme-quickstart.py @@ -1,7 +1,7 @@ -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer # Create an MCP server -mcp = FastMCP("Demo") +mcp = MCPServer("Demo") # Add an addition tool diff --git a/examples/fastmcp/screenshot.py b/examples/mcpserver/screenshot.py similarity index 64% rename from examples/fastmcp/screenshot.py rename to examples/mcpserver/screenshot.py index 694b49f2f..e7b3ee6fb 100644 --- a/examples/fastmcp/screenshot.py +++ b/examples/mcpserver/screenshot.py @@ -1,22 +1,20 @@ -""" -FastMCP Screenshot Example +"""MCPServer Screenshot Example Give Claude a tool to capture and view screenshots. """ import io -from mcp.server.fastmcp import FastMCP -from mcp.server.fastmcp.utilities.types import Image +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.utilities.types import Image # Create server -mcp = FastMCP("Screenshot Demo", dependencies=["pyautogui", "Pillow"]) +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 + """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 diff --git a/examples/fastmcp/simple_echo.py b/examples/mcpserver/simple_echo.py similarity index 50% rename from examples/fastmcp/simple_echo.py rename to examples/mcpserver/simple_echo.py index c26152646..3d8142a66 100644 --- a/examples/fastmcp/simple_echo.py +++ b/examples/mcpserver/simple_echo.py @@ -1,11 +1,9 @@ -""" -FastMCP Echo Server -""" +"""MCPServer Echo Server""" -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer # Create server -mcp = FastMCP("Echo Server") +mcp = MCPServer("Echo Server") @mcp.tool() diff --git a/examples/fastmcp/text_me.py b/examples/mcpserver/text_me.py similarity index 89% rename from examples/fastmcp/text_me.py rename to examples/mcpserver/text_me.py index 2434dcddd..7aeb54362 100644 --- a/examples/fastmcp/text_me.py +++ b/examples/mcpserver/text_me.py @@ -2,10 +2,9 @@ # dependencies = [] # /// -""" -FastMCP Text Me Server +"""MCPServer Text Me Server -------------------------------- -This defines a simple FastMCP server that sends a text message to a phone number via https://surgemsg.com/. +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: @@ -24,7 +23,7 @@ from pydantic import BeforeValidator from pydantic_settings import BaseSettings, SettingsConfigDict -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer class SurgeSettings(BaseSettings): @@ -38,7 +37,7 @@ class SurgeSettings(BaseSettings): # Create server -mcp = FastMCP("Text me") +mcp = MCPServer("Text me") surge_settings = SurgeSettings() # type: ignore diff --git a/examples/fastmcp/unicode_example.py b/examples/mcpserver/unicode_example.py similarity index 88% rename from examples/fastmcp/unicode_example.py rename to examples/mcpserver/unicode_example.py index bb487f618..012633ec7 100644 --- a/examples/fastmcp/unicode_example.py +++ b/examples/mcpserver/unicode_example.py @@ -1,17 +1,15 @@ -""" -Example FastMCP server that uses Unicode characters in various places to help test +"""Example MCPServer server that uses Unicode characters in various places to help test Unicode handling in tools and inspectors. """ -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer -mcp = FastMCP() +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: + """A simple tool that demonstrates Unicode handling in: - Tool description (emojis, accents, CJK characters) - Parameter defaults (CJK characters) - Return values (Spanish punctuation, emojis) diff --git a/examples/fastmcp/weather_structured.py b/examples/mcpserver/weather_structured.py similarity index 89% rename from examples/fastmcp/weather_structured.py rename to examples/mcpserver/weather_structured.py index 20cbf7957..958c7d319 100644 --- a/examples/fastmcp/weather_structured.py +++ b/examples/mcpserver/weather_structured.py @@ -1,5 +1,4 @@ -""" -FastMCP Weather Example with Structured Output +"""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. @@ -14,11 +13,11 @@ from pydantic import BaseModel, Field -from mcp.server.fastmcp import FastMCP -from mcp.shared.memory import create_connected_server_and_client_session as client_session +from mcp.client import Client +from mcp.server.mcpserver import MCPServer # Create server -mcp = FastMCP("Weather Service") +mcp = MCPServer("Weather Service") # Example 1: Using a Pydantic model for structured output @@ -157,36 +156,36 @@ async def test() -> None: print("Testing Weather Service Tools (via MCP protocol)\n") print("=" * 80) - async with client_session(mcp._mcp_server) as client: + 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.structuredContent, indent=2)) + 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.structuredContent, indent=2)) + 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.structuredContent, indent=2)) + 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.structuredContent, indent=2)) + 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.structuredContent, indent=2)) + 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.structuredContent, indent=2)) + print(json.dumps(result.structured_content, indent=2)) # Also show the text content for comparison print("\nText content for last result:") @@ -204,11 +203,11 @@ async def print_schemas() -> None: print(f"\nTool: {tool.name}") print(f"Description: {tool.description}") print("Input Schema:") - print(json.dumps(tool.inputSchema, indent=2)) + print(json.dumps(tool.input_schema, indent=2)) - if tool.outputSchema: + if tool.output_schema: print("Output Schema:") - print(json.dumps(tool.outputSchema, indent=2)) + print(json.dumps(tool.output_schema, indent=2)) else: print("Output Schema: None (returns unstructured content)") diff --git a/examples/servers/everything-server/mcp_everything_server/server.py b/examples/servers/everything-server/mcp_everything_server/server.py index 1f1ee7ecc..2101cff28 100644 --- a/examples/servers/everything-server/mcp_everything_server/server.py +++ b/examples/servers/everything-server/mcp_everything_server/server.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -MCP Everything Server - Conformance Test Server +"""MCP Everything Server - Conformance Test Server Server implementing all MCP features for conformance testing based on Conformance Server Specification. """ @@ -11,8 +10,9 @@ import logging import click -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.fastmcp.prompts.base import UserMessage +from mcp.server import ServerRequestContext +from mcp.server.mcpserver import Context, MCPServer +from mcp.server.mcpserver.prompts.base import UserMessage from mcp.server.session import ServerSession from mcp.server.streamable_http import EventCallback, EventMessage, EventStore from mcp.types import ( @@ -21,15 +21,19 @@ CompletionArgument, CompletionContext, EmbeddedResource, + EmptyResult, ImageContent, JSONRPCMessage, PromptReference, ResourceTemplateReference, SamplingMessage, + SetLevelRequestParams, + SubscribeRequestParams, TextContent, TextResourceContents, + UnsubscribeRequestParams, ) -from pydantic import AnyUrl, BaseModel, Field +from pydantic import BaseModel, Field logger = logging.getLogger(__name__) @@ -81,10 +85,8 @@ async def replay_events_after(self, last_event_id: EventId, send_callback: Event # Create event store for SSE resumability (SEP-1699) event_store = InMemoryEventStore() -mcp = FastMCP( +mcp = MCPServer( name="mcp-conformance-test-server", - event_store=event_store, - retry_interval=100, # 100ms retry interval for SSE polling ) @@ -98,13 +100,13 @@ def test_simple_text() -> str: @mcp.tool() def test_image_content() -> list[ImageContent]: """Tests image content response""" - return [ImageContent(type="image", data=TEST_IMAGE_BASE64, mimeType="image/png")] + 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, mimeType="audio/wav")] + return [AudioContent(type="audio", data=TEST_AUDIO_BASE64, mime_type="audio/wav")] @mcp.tool() @@ -114,8 +116,8 @@ def test_embedded_resource() -> list[EmbeddedResource]: EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("test://embedded-resource"), - mimeType="text/plain", + uri="test://embedded-resource", + mime_type="text/plain", text="This is an embedded resource content.", ), ) @@ -127,12 +129,12 @@ def test_multiple_content_types() -> list[TextContent | ImageContent | EmbeddedR """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, mimeType="image/png"), + ImageContent(type="image", data=TEST_IMAGE_BASE64, mime_type="image/png"), EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("test://mixed-content-resource"), - mimeType="application/json", + uri="test://mixed-content-resource", + mime_type="application/json", text='{"test": "data", "value": 123}', ), ), @@ -164,7 +166,9 @@ async def test_tool_with_progress(ctx: Context[ServerSession, None]) -> str: 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.progressToken if ctx.request_context and ctx.request_context.meta else 0 + progress_token = ( + ctx.request_context.meta.get("progress_token") if ctx.request_context and ctx.request_context.meta else 0 + ) return str(progress_token) @@ -372,8 +376,8 @@ def test_prompt_with_embedded_resource(resourceUri: str) -> list[UserMessage]: content=EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl(resourceUri), - mimeType="text/plain", + uri=resourceUri, + mime_type="text/plain", text="Embedded resource content for testing.", ), ), @@ -386,36 +390,37 @@ def test_prompt_with_embedded_resource(resourceUri: str) -> list[UserMessage]: 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, mimeType="image/png")), + 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.")), ] # Custom request handlers -# TODO(felix): Add public APIs to FastMCP for subscribe_resource, unsubscribe_resource, -# and set_logging_level to avoid accessing protected _mcp_server attribute. -@mcp._mcp_server.set_logging_level() # pyright: ignore[reportPrivateUsage] -async def handle_set_logging_level(level: str) -> None: +# 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: {level}") - # In a real implementation, you would adjust the logging level here - # For conformance testing, we just acknowledge the request + logger.info(f"Log level set to: {params.level}") + return EmptyResult() -async def handle_subscribe(uri: AnyUrl) -> None: +async def handle_subscribe(ctx: ServerRequestContext, params: SubscribeRequestParams) -> EmptyResult: """Handle resource subscription""" - resource_subscriptions.add(str(uri)) - logger.info(f"Subscribed to resource: {uri}") + resource_subscriptions.add(str(params.uri)) + logger.info(f"Subscribed to resource: {params.uri}") + return EmptyResult() -async def handle_unsubscribe(uri: AnyUrl) -> None: +async def handle_unsubscribe(ctx: ServerRequestContext, params: UnsubscribeRequestParams) -> EmptyResult: """Handle resource unsubscription""" - resource_subscriptions.discard(str(uri)) - logger.info(f"Unsubscribed from resource: {uri}") + resource_subscriptions.discard(str(params.uri)) + logger.info(f"Unsubscribed from resource: {params.uri}") + return EmptyResult() -mcp._mcp_server.subscribe_resource()(handle_subscribe) # pyright: ignore[reportPrivateUsage] -mcp._mcp_server.unsubscribe_resource()(handle_unsubscribe) # pyright: ignore[reportPrivateUsage] +mcp._lowlevel_server._add_request_handler("logging/setLevel", handle_set_logging_level) # pyright: ignore[reportPrivateUsage] +mcp._lowlevel_server._add_request_handler("resources/subscribe", handle_subscribe) # pyright: ignore[reportPrivateUsage] +mcp._lowlevel_server._add_request_handler("resources/unsubscribe", handle_unsubscribe) # pyright: ignore[reportPrivateUsage] @mcp.completion() @@ -427,7 +432,7 @@ async def _handle_completion( """Handle completion requests""" # Basic completion support - returns empty array for conformance # Real implementations would provide contextual suggestions - return Completion(values=[], total=0, hasMore=False) + return Completion(values=[], total=0, has_more=False) # CLI @@ -448,8 +453,12 @@ def main(port: int, log_level: str) -> int: logger.info(f"Starting MCP Everything Server on port {port}") logger.info(f"Endpoint will be: http://localhost:{port}/mcp") - mcp.settings.port = port - mcp.run(transport="streamable-http") + mcp.run( + transport="streamable-http", + port=port, + event_store=event_store, + retry_interval=100, # 100ms retry interval for SSE polling + ) return 0 diff --git a/examples/servers/everything-server/pyproject.toml b/examples/servers/everything-server/pyproject.toml index ff67bf557..f68a9d282 100644 --- a/examples/servers/everything-server/pyproject.toml +++ b/examples/servers/everything-server/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "Comprehensive MCP server implementing all protocol features for conformance testing" readme = "README.md" requires-python = ">=3.10" -authors = [{ name = "Anthropic, PBC." }] +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"] diff --git a/examples/servers/simple-auth/README.md b/examples/servers/simple-auth/README.md index b80e98a04..d4a10c43b 100644 --- a/examples/servers/simple-auth/README.md +++ b/examples/servers/simple-auth/README.md @@ -31,10 +31,10 @@ uv run mcp-simple-auth-as --port=9000 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 +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 +uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http --oauth-strict ``` @@ -84,8 +84,9 @@ For backwards compatibility with older MCP implementations, a legacy server is p ### Running the Legacy Server ```bash -# Start legacy authorization server on port 8002 -uv run mcp-simple-auth-legacy --port=8002 +# 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:** @@ -101,7 +102,7 @@ uv run mcp-simple-auth-legacy --port=8002 ```bash # Test with client (will automatically fall back to legacy discovery) cd examples/clients/simple-auth-client -MCP_SERVER_PORT=8002 MCP_TRANSPORT_TYPE=streamable-http uv run mcp-simple-auth-client +MCP_SERVER_PORT=8000 MCP_TRANSPORT_TYPE=streamable-http uv run mcp-simple-auth-client ``` The client will: diff --git a/examples/servers/simple-auth/mcp_simple_auth/auth_server.py b/examples/servers/simple-auth/mcp_simple_auth/auth_server.py index 80a2e8b8a..9d13fffe4 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/auth_server.py +++ b/examples/servers/simple-auth/mcp_simple_auth/auth_server.py @@ -1,5 +1,4 @@ -""" -Authorization Server for MCP Split Demo. +"""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. @@ -41,8 +40,7 @@ class AuthServerSettings(BaseModel): class SimpleAuthProvider(SimpleOAuthProvider): - """ - Authorization Server provider with simple demo authentication. + """Authorization Server provider with simple demo authentication. This provider: 1. Issues MCP tokens after simple credential authentication @@ -98,8 +96,7 @@ async def login_callback_handler(request: Request) -> Response: # Add token introspection endpoint (RFC 7662) for Resource Servers async def introspect_handler(request: Request) -> Response: - """ - Token introspection endpoint for Resource Servers. + """Token introspection endpoint for Resource Servers. Resource Servers call this endpoint to validate tokens without needing direct access to token storage. @@ -157,8 +154,7 @@ async def run_server(server_settings: AuthServerSettings, auth_settings: SimpleA @click.command() @click.option("--port", default=9000, help="Port to listen on") def main(port: int) -> int: - """ - Run the MCP Authorization Server. + """Run the MCP Authorization Server. This server handles OAuth flows and can be used by multiple Resource Servers. 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 index b0455c3e8..ab7773b5b 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/legacy_as_server.py +++ b/examples/servers/simple-auth/mcp_simple_auth/legacy_as_server.py @@ -1,5 +1,4 @@ -""" -Legacy Combined Authorization Server + Resource Server for MCP. +"""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. @@ -20,7 +19,7 @@ from starlette.responses import Response from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions -from mcp.server.fastmcp.server import FastMCP +from mcp.server.mcpserver.server import MCPServer from .simple_auth_provider import SimpleAuthSettings, SimpleOAuthProvider @@ -44,8 +43,8 @@ def __init__(self, auth_settings: SimpleAuthSettings, auth_callback_path: str, s super().__init__(auth_settings, auth_callback_path, server_url) -def create_simple_mcp_server(server_settings: ServerSettings, auth_settings: SimpleAuthSettings) -> FastMCP: - """Create a simple FastMCP server with simple authentication.""" +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) ) @@ -62,15 +61,15 @@ def create_simple_mcp_server(server_settings: ServerSettings, auth_settings: Sim resource_server_url=None, ) - app = FastMCP( + app = MCPServer( name="Simple Auth MCP Server", instructions="A simple MCP server with simple credential authentication", auth_server_provider=oauth_provider, - host=server_settings.host, - port=server_settings.port, 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: @@ -87,8 +86,7 @@ async def login_callback_handler(request: Request) -> Response: @app.tool() async def get_time() -> dict[str, Any]: - """ - Get the current server time. + """Get the current server time. This tool demonstrates that system information can be protected by OAuth authentication. User must be authenticated to access it. @@ -131,7 +129,7 @@ def main(port: int, transport: Literal["sse", "streamable-http"]) -> int: 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) + mcp_server.run(transport=transport, host=host, port=port) return 0 diff --git a/tests/server/fastmcp/__init__.py b/examples/servers/simple-auth/mcp_simple_auth/py.typed similarity index 100% rename from tests/server/fastmcp/__init__.py rename to examples/servers/simple-auth/mcp_simple_auth/py.typed diff --git a/examples/servers/simple-auth/mcp_simple_auth/server.py b/examples/servers/simple-auth/mcp_simple_auth/server.py index 5d8850570..0320871b1 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/server.py +++ b/examples/servers/simple-auth/mcp_simple_auth/server.py @@ -1,5 +1,4 @@ -""" -MCP Resource Server with Token Introspection. +"""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. @@ -17,7 +16,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict from mcp.server.auth.settings import AuthSettings -from mcp.server.fastmcp.server import FastMCP +from mcp.server.mcpserver.server import MCPServer from .token_verifier import IntrospectionTokenVerifier @@ -46,9 +45,8 @@ class ResourceServerSettings(BaseSettings): oauth_strict: bool = False -def create_resource_server(settings: ResourceServerSettings) -> FastMCP: - """ - Create MCP Resource Server with token introspection. +def create_resource_server(settings: ResourceServerSettings) -> MCPServer: + """Create MCP Resource Server with token introspection. This server: 1. Provides protected resource metadata (RFC 9728) @@ -62,12 +60,10 @@ def create_resource_server(settings: ResourceServerSettings) -> FastMCP: validate_resource=settings.oauth_strict, # Only validate when --oauth-strict is set ) - # Create FastMCP server as a Resource Server - app = FastMCP( + # Create MCPServer server as a Resource Server + app = MCPServer( name="MCP Resource Server", instructions="Resource Server that validates tokens via Authorization Server introspection", - host=settings.host, - port=settings.port, debug=True, # Auth configuration for RS mode token_verifier=token_verifier, @@ -77,11 +73,12 @@ def create_resource_server(settings: ResourceServerSettings) -> FastMCP: 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. + """Get the current server time. This tool demonstrates that system information can be protected by OAuth authentication. User must be authenticated to access it. @@ -114,8 +111,7 @@ async def get_time() -> dict[str, Any]: 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. + """Run the MCP Resource Server. This server: - Provides RFC 9728 Protected Resource Metadata @@ -153,7 +149,7 @@ def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http logger.info(f"🔑 Using Authorization Server: {settings.auth_server_url}") # Run the server - this should block and keep running - mcp_server.run(transport=transport) + mcp_server.run(transport=transport, host=host, port=port) logger.info("Server stopped") return 0 except Exception: 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 index e3a25d3e8..3a3895cc5 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py +++ b/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py @@ -1,5 +1,4 @@ -""" -Simple OAuth provider for MCP servers. +"""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. @@ -9,7 +8,6 @@ """ -import logging import secrets import time from typing import Any @@ -30,8 +28,6 @@ ) from mcp.shared.auth import OAuthClientInformationFull, OAuthToken -logger = logging.getLogger(__name__) - class SimpleAuthSettings(BaseSettings): """Simple OAuth settings for demo purposes.""" @@ -47,8 +43,7 @@ class SimpleAuthSettings(BaseSettings): class SimpleOAuthProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]): - """ - Simple OAuth provider for demo purposes. + """Simple OAuth provider for demo purposes. This provider handles the OAuth flow by: 1. Providing a simple login form for demo credentials diff --git a/examples/servers/simple-auth/pyproject.toml b/examples/servers/simple-auth/pyproject.toml index eb2b18561..1ffe3e694 100644 --- a/examples/servers/simple-auth/pyproject.toml +++ b/examples/servers/simple-auth/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "A simple MCP server demonstrating OAuth authentication" readme = "README.md" requires-python = ">=3.10" -authors = [{ name = "Anthropic, PBC." }] +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] license = { text = "MIT" } dependencies = [ "anyio>=4.5", diff --git a/examples/servers/simple-pagination/mcp_simple_pagination/server.py b/examples/servers/simple-pagination/mcp_simple_pagination/server.py index 360cbc3cf..bac27a0f1 100644 --- a/examples/servers/simple-pagination/mcp_simple_pagination/server.py +++ b/examples/servers/simple-pagination/mcp_simple_pagination/server.py @@ -1,33 +1,33 @@ -""" -Simple MCP server demonstrating pagination for tools, resources, and prompts. +"""Simple MCP server demonstrating pagination for tools, resources, and prompts. -This example shows how to use the paginated decorators to handle large lists -of items that need to be split across multiple pages. +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 Any +from typing import TypeVar import anyio import click -import mcp.types as types -from mcp.server.lowlevel import Server -from pydantic import AnyUrl +from mcp import types +from mcp.server import Server, ServerRequestContext from starlette.requests import Request +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}", - inputSchema={"type": "object", "properties": {"input": {"type": "string"}}}, + input_schema={"type": "object", "properties": {"input": {"type": "string"}}}, ) for i in range(1, 26) # 25 tools total ] SAMPLE_RESOURCES = [ types.Resource( - uri=AnyUrl(f"file:///path/to/resource_{i}.txt"), + uri=f"file:///path/to/resource_{i}.txt", name=f"resource_{i}", description=f"This is sample resource number {i}", ) @@ -46,6 +46,102 @@ ] +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 SSE") @click.option( @@ -55,142 +151,15 @@ help="Transport type", ) def main(port: int, transport: str) -> int: - app = Server("mcp-simple-pagination") - - # Paginated list_tools - returns 5 tools per page - @app.list_tools() - async def list_tools_paginated(request: types.ListToolsRequest) -> types.ListToolsResult: - page_size = 5 - - cursor = request.params.cursor if request.params is not None else None - if cursor is None: - # First page - start_idx = 0 - else: - # Parse cursor to get the start index - try: - start_idx = int(cursor) - except (ValueError, TypeError): - # Invalid cursor, return empty - return types.ListToolsResult(tools=[], nextCursor=None) - - # Get the page of tools - page_tools = SAMPLE_TOOLS[start_idx : start_idx + page_size] - - # Determine if there are more pages - next_cursor = None - if start_idx + page_size < len(SAMPLE_TOOLS): - next_cursor = str(start_idx + page_size) - - return types.ListToolsResult(tools=page_tools, nextCursor=next_cursor) - - # Paginated list_resources - returns 10 resources per page - @app.list_resources() - async def list_resources_paginated( - request: types.ListResourcesRequest, - ) -> types.ListResourcesResult: - page_size = 10 - - cursor = request.params.cursor if request.params is not None else None - if cursor is None: - # First page - start_idx = 0 - else: - # Parse cursor to get the start index - try: - start_idx = int(cursor) - except (ValueError, TypeError): - # Invalid cursor, return empty - return types.ListResourcesResult(resources=[], nextCursor=None) - - # Get the page of resources - page_resources = SAMPLE_RESOURCES[start_idx : start_idx + page_size] - - # Determine if there are more pages - next_cursor = None - if start_idx + page_size < len(SAMPLE_RESOURCES): - next_cursor = str(start_idx + page_size) - - return types.ListResourcesResult(resources=page_resources, nextCursor=next_cursor) - - # Paginated list_prompts - returns 7 prompts per page - @app.list_prompts() - async def list_prompts_paginated( - request: types.ListPromptsRequest, - ) -> types.ListPromptsResult: - page_size = 7 - - cursor = request.params.cursor if request.params is not None else None - if cursor is None: - # First page - start_idx = 0 - else: - # Parse cursor to get the start index - try: - start_idx = int(cursor) - except (ValueError, TypeError): - # Invalid cursor, return empty - return types.ListPromptsResult(prompts=[], nextCursor=None) - - # Get the page of prompts - page_prompts = SAMPLE_PROMPTS[start_idx : start_idx + page_size] - - # Determine if there are more pages - next_cursor = None - if start_idx + page_size < len(SAMPLE_PROMPTS): - next_cursor = str(start_idx + page_size) - - return types.ListPromptsResult(prompts=page_prompts, nextCursor=next_cursor) - - # Implement call_tool handler - @app.call_tool() - async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: - # Find the tool in our sample data - tool = next((t for t in SAMPLE_TOOLS if t.name == name), None) - if not tool: - raise ValueError(f"Unknown tool: {name}") - - # Simple mock response - return [ - types.TextContent( - type="text", - text=f"Called tool '{name}' with arguments: {arguments}", - ) - ] - - # Implement read_resource handler - @app.read_resource() - async def read_resource(uri: AnyUrl) -> str: - # Find the resource in our sample data - resource = next((r for r in SAMPLE_RESOURCES if r.uri == uri), None) - if not resource: - raise ValueError(f"Unknown resource: {uri}") - - # Return a simple string - the decorator will convert it to TextResourceContents - return f"Content of {resource.name}: This is sample content for the resource." - - # Implement get_prompt handler - @app.get_prompt() - async def get_prompt(name: str, arguments: dict[str, str] | None) -> types.GetPromptResult: - # Find the prompt in our sample data - prompt = next((p for p in SAMPLE_PROMPTS if p.name == name), None) - if not prompt: - raise ValueError(f"Unknown prompt: {name}") - - # Simple mock response - message_text = f"This is the prompt '{name}'" - if arguments: - message_text += f" with arguments: {arguments}" - - return types.GetPromptResult( - description=prompt.description, - messages=[ - types.PromptMessage( - role="user", - content=types.TextContent(type="text", text=message_text), - ) - ], - ) + 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 == "sse": from mcp.server.sse import SseServerTransport diff --git a/examples/servers/simple-pagination/pyproject.toml b/examples/servers/simple-pagination/pyproject.toml index 14de50257..2d57d9ccc 100644 --- a/examples/servers/simple-pagination/pyproject.toml +++ b/examples/servers/simple-pagination/pyproject.toml @@ -4,11 +4,7 @@ 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 = "Anthropic, PBC." }] -maintainers = [ - { name = "David Soria Parra", email = "davidsp@anthropic.com" }, - { name = "Justin Spahr-Summers", email = "justin@anthropic.com" }, -] +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] keywords = ["mcp", "llm", "automation", "pagination", "cursor"] license = { text = "MIT" } classifiers = [ diff --git a/examples/servers/simple-prompt/mcp_simple_prompt/server.py b/examples/servers/simple-prompt/mcp_simple_prompt/server.py index 76b598f93..6cf99d4b6 100644 --- a/examples/servers/simple-prompt/mcp_simple_prompt/server.py +++ b/examples/servers/simple-prompt/mcp_simple_prompt/server.py @@ -1,7 +1,7 @@ import anyio import click -import mcp.types as types -from mcp.server.lowlevel import Server +from mcp import types +from mcp.server import Server, ServerRequestContext from starlette.requests import Request @@ -30,20 +30,11 @@ def create_messages(context: str | None = None, topic: str | None = None) -> lis return messages -@click.command() -@click.option("--port", default=8000, help="Port to listen on for SSE") -@click.option( - "--transport", - type=click.Choice(["stdio", "sse"]), - default="stdio", - help="Transport type", -) -def main(port: int, transport: str) -> int: - app = Server("mcp-simple-prompt") - - @app.list_prompts() - async def list_prompts() -> list[types.Prompt]: - return [ +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", @@ -62,19 +53,35 @@ async def list_prompts() -> list[types.Prompt]: ], ) ] + ) - @app.get_prompt() - async def get_prompt(name: str, arguments: dict[str, str] | None = None) -> types.GetPromptResult: - if name != "simple": - raise ValueError(f"Unknown prompt: {name}") - if arguments is None: - arguments = {} +async def handle_get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> types.GetPromptResult: + if params.name != "simple": + raise ValueError(f"Unknown prompt: {params.name}") - return types.GetPromptResult( - messages=create_messages(context=arguments.get("context"), topic=arguments.get("topic")), - description="A simple prompt with optional context and topic arguments", - ) + 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 SSE") +@click.option( + "--transport", + type=click.Choice(["stdio", "sse"]), + 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 == "sse": from mcp.server.sse import SseServerTransport diff --git a/examples/servers/simple-prompt/pyproject.toml b/examples/servers/simple-prompt/pyproject.toml index 28fe26574..9d4d8e6a6 100644 --- a/examples/servers/simple-prompt/pyproject.toml +++ b/examples/servers/simple-prompt/pyproject.toml @@ -4,11 +4,7 @@ version = "0.1.0" description = "A simple MCP server exposing a customizable prompt" readme = "README.md" requires-python = ">=3.10" -authors = [{ name = "Anthropic, PBC." }] -maintainers = [ - { name = "David Soria Parra", email = "davidsp@anthropic.com" }, - { name = "Justin Spahr-Summers", email = "justin@anthropic.com" }, -] +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] keywords = ["mcp", "llm", "automation", "web", "fetch"] license = { text = "MIT" } classifiers = [ diff --git a/examples/servers/simple-resource/mcp_simple_resource/server.py b/examples/servers/simple-resource/mcp_simple_resource/server.py index 151a23eab..b9b6a1d96 100644 --- a/examples/servers/simple-resource/mcp_simple_resource/server.py +++ b/examples/servers/simple-resource/mcp_simple_resource/server.py @@ -1,9 +1,9 @@ +from urllib.parse import urlparse + import anyio import click -import mcp.types as types -from mcp.server.lowlevel import Server -from mcp.server.lowlevel.helper_types import ReadResourceContents -from pydantic import AnyUrl, FileUrl +from mcp import types +from mcp.server import Server, ServerRequestContext from starlette.requests import Request SAMPLE_RESOURCES = { @@ -22,40 +22,59 @@ } -@click.command() -@click.option("--port", default=8000, help="Port to listen on for SSE") -@click.option( - "--transport", - type=click.Choice(["stdio", "sse"]), - default="stdio", - help="Transport type", -) -def main(port: int, transport: str) -> int: - app = Server("mcp-simple-resource") - - @app.list_resources() - async def list_resources() -> list[types.Resource]: - return [ +async def handle_list_resources( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListResourcesResult: + return types.ListResourcesResult( + resources=[ types.Resource( - uri=FileUrl(f"file:///{name}.txt"), + uri=f"file:///{name}.txt", name=name, title=SAMPLE_RESOURCES[name]["title"], description=f"A sample text resource named {name}", - mimeType="text/plain", + 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", + ) + ] + ) - @app.read_resource() - async def read_resource(uri: AnyUrl): - if uri.path is None: - raise ValueError(f"Invalid resource path: {uri}") - name = uri.path.replace(".txt", "").lstrip("/") - - if name not in SAMPLE_RESOURCES: - raise ValueError(f"Unknown resource: {uri}") - return [ReadResourceContents(content=SAMPLE_RESOURCES[name]["content"], mime_type="text/plain")] +@click.command() +@click.option("--port", default=8000, help="Port to listen on for SSE") +@click.option( + "--transport", + type=click.Choice(["stdio", "sse"]), + 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 == "sse": from mcp.server.sse import SseServerTransport diff --git a/examples/servers/simple-resource/pyproject.toml b/examples/servers/simple-resource/pyproject.toml index 14c2bd38c..34fbc8d9d 100644 --- a/examples/servers/simple-resource/pyproject.toml +++ b/examples/servers/simple-resource/pyproject.toml @@ -4,11 +4,7 @@ version = "0.1.0" description = "A simple MCP server exposing sample text resources" readme = "README.md" requires-python = ">=3.10" -authors = [{ name = "Anthropic, PBC." }] -maintainers = [ - { name = "David Soria Parra", email = "davidsp@anthropic.com" }, - { name = "Justin Spahr-Summers", email = "justin@anthropic.com" }, -] +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] keywords = ["mcp", "llm", "automation", "web", "fetch"] license = { text = "MIT" } classifiers = [ 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 index f1b3987d2..cb4a6503c 100644 --- a/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py +++ b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py @@ -1,12 +1,12 @@ import contextlib import logging from collections.abc import AsyncIterator -from typing import Any import anyio import click -import mcp.types as types -from mcp.server.lowlevel import Server +import uvicorn +from mcp import types +from mcp.server import Server, ServerRequestContext from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from starlette.applications import Starlette from starlette.middleware.cors import CORSMiddleware @@ -16,6 +16,64 @@ 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( + 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( @@ -33,66 +91,18 @@ def main( port: int, log_level: str, json_response: bool, -) -> int: +) -> 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") - - @app.call_tool() - async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: - ctx = app.request_context - 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( - 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.TextContent( - type="text", - text=(f"Sent {count} notifications with {interval}s interval for caller: {caller}"), - ) - ] - - @app.list_tools() - async def list_tools() -> list[types.Tool]: - return [ - types.Tool( - name="start-notification-stream", - description=("Sends a stream of notifications with configurable count and interval"), - inputSchema={ - "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"), - }, - }, - }, - ) - ] + app = Server( + "mcp-streamable-http-stateless-demo", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, + ) # Create the session manager with true stateless mode session_manager = StreamableHTTPSessionManager( @@ -118,9 +128,7 @@ async def lifespan(app: Starlette) -> AsyncIterator[None]: # Create an ASGI application using the transport starlette_app = Starlette( debug=True, - routes=[ - Mount("/mcp", app=handle_streamable_http), - ], + routes=[Mount("/mcp", app=handle_streamable_http)], lifespan=lifespan, ) @@ -133,8 +141,4 @@ async def lifespan(app: Starlette) -> AsyncIterator[None]: expose_headers=["Mcp-Session-Id"], ) - import uvicorn - uvicorn.run(starlette_app, host="127.0.0.1", port=port) - - return 0 diff --git a/examples/servers/simple-streamablehttp-stateless/pyproject.toml b/examples/servers/simple-streamablehttp-stateless/pyproject.toml index 0e695695c..38f7b1b39 100644 --- a/examples/servers/simple-streamablehttp-stateless/pyproject.toml +++ b/examples/servers/simple-streamablehttp-stateless/pyproject.toml @@ -4,7 +4,7 @@ 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 = "Anthropic, PBC." }] +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"] diff --git a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py index 0c3081ed6..3501fa47c 100644 --- a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py +++ b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py @@ -1,5 +1,4 @@ -""" -In-memory event store for demonstrating resumability functionality. +"""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. @@ -18,9 +17,7 @@ @dataclass class EventEntry: - """ - Represents an event entry in the event store. - """ + """Represents an event entry in the event store.""" event_id: EventId stream_id: StreamId @@ -28,8 +25,7 @@ class EventEntry: class InMemoryEventStore(EventStore): - """ - Simple in-memory implementation of the EventStore interface for resumability. + """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. diff --git a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py index 4b2604b9a..2f2a53b1b 100644 --- a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py +++ b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py @@ -1,14 +1,12 @@ import contextlib import logging from collections.abc import AsyncIterator -from typing import Any import anyio import click -import mcp.types as types -from mcp.server.lowlevel import Server +from mcp import types +from mcp.server import Server, ServerRequestContext from mcp.server.streamable_http_manager import StreamableHTTPSessionManager -from pydantic import AnyUrl from starlette.applications import Starlette from starlette.middleware.cors import CORSMiddleware from starlette.routing import Mount @@ -20,6 +18,75 @@ 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( + 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( @@ -44,70 +111,11 @@ def main( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) - app = Server("mcp-streamable-http-demo") - - @app.call_tool() - async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: - ctx = app.request_context - 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( - 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 notificaiton though standalone SSE - # established by GET request - await ctx.session.send_resource_updated(uri=AnyUrl("http:///test_resource")) - return [ - types.TextContent( - type="text", - text=(f"Sent {count} notifications with {interval}s interval for caller: {caller}"), - ) - ] - - @app.list_tools() - async def list_tools() -> list[types.Tool]: - return [ - types.Tool( - name="start-notification-stream", - description=("Sends a stream of notifications with configurable count and interval"), - inputSchema={ - "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"), - }, - }, - }, - ) - ] + 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. diff --git a/examples/servers/simple-streamablehttp/pyproject.toml b/examples/servers/simple-streamablehttp/pyproject.toml index f0404fb7d..93f7baf41 100644 --- a/examples/servers/simple-streamablehttp/pyproject.toml +++ b/examples/servers/simple-streamablehttp/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "A simple MCP server exposing a StreamableHttp transport for testing" readme = "README.md" requires-python = ">=3.10" -authors = [{ name = "Anthropic, PBC." }] +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"] diff --git a/examples/servers/simple-task-interactive/mcp_simple_task_interactive/server.py b/examples/servers/simple-task-interactive/mcp_simple_task_interactive/server.py index 4d35ca809..6938b6552 100644 --- a/examples/servers/simple-task-interactive/mcp_simple_task_interactive/server.py +++ b/examples/servers/simple-task-interactive/mcp_simple_task_interactive/server.py @@ -11,44 +11,41 @@ from typing import Any import click -import mcp.types as types import uvicorn +from mcp import types +from mcp.server import Server, ServerRequestContext from mcp.server.experimental.task_context import ServerTaskContext -from mcp.server.lowlevel import Server from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from starlette.applications import Starlette from starlette.routing import Mount -server = Server("simple-task-interactive") -# Enable task support - this auto-registers all handlers -server.experimental.enable_tasks() +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="confirm_delete", + description="Asks for confirmation before deleting (demonstrates elicitation)", + input_schema={ + "type": "object", + "properties": {"filename": {"type": "string"}}, + }, + execution=types.ToolExecution(task_support=types.TASK_REQUIRED), + ), + types.Tool( + name="write_haiku", + description="Asks LLM to write a haiku (demonstrates sampling)", + input_schema={"type": "object", "properties": {"topic": {"type": "string"}}}, + execution=types.ToolExecution(task_support=types.TASK_REQUIRED), + ), + ] + ) -@server.list_tools() -async def list_tools() -> list[types.Tool]: - return [ - types.Tool( - name="confirm_delete", - description="Asks for confirmation before deleting (demonstrates elicitation)", - inputSchema={ - "type": "object", - "properties": {"filename": {"type": "string"}}, - }, - execution=types.ToolExecution(taskSupport=types.TASK_REQUIRED), - ), - types.Tool( - name="write_haiku", - description="Asks LLM to write a haiku (demonstrates sampling)", - inputSchema={"type": "object", "properties": {"topic": {"type": "string"}}}, - execution=types.ToolExecution(taskSupport=types.TASK_REQUIRED), - ), - ] - - -async def handle_confirm_delete(arguments: dict[str, Any]) -> types.CreateTaskResult: +async def handle_confirm_delete(ctx: ServerRequestContext, arguments: dict[str, Any]) -> types.CreateTaskResult: """Handle the confirm_delete tool - demonstrates elicitation.""" - ctx = server.request_context ctx.experimental.validate_task_mode(types.TASK_REQUIRED) filename = arguments.get("filename", "unknown.txt") @@ -59,7 +56,7 @@ async def work(task: ServerTaskContext) -> types.CallToolResult: result = await task.elicit( message=f"Are you sure you want to delete '{filename}'?", - requestedSchema={ + requested_schema={ "type": "object", "properties": {"confirm": {"type": "boolean"}}, "required": ["confirm"], @@ -80,9 +77,8 @@ async def work(task: ServerTaskContext) -> types.CallToolResult: return await ctx.experimental.run_task(work) -async def handle_write_haiku(arguments: dict[str, Any]) -> types.CreateTaskResult: +async def handle_write_haiku(ctx: ServerRequestContext, arguments: dict[str, Any]) -> types.CreateTaskResult: """Handle the write_haiku tool - demonstrates sampling.""" - ctx = server.request_context ctx.experimental.validate_task_mode(types.TASK_REQUIRED) topic = arguments.get("topic", "nature") @@ -111,18 +107,31 @@ async def work(task: ServerTaskContext) -> types.CallToolResult: return await ctx.experimental.run_task(work) -@server.call_tool() -async def handle_call_tool(name: str, arguments: dict[str, Any]) -> types.CallToolResult | types.CreateTaskResult: +async def handle_call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams +) -> types.CallToolResult | types.CreateTaskResult: """Dispatch tool calls to their handlers.""" - if name == "confirm_delete": - return await handle_confirm_delete(arguments) - elif name == "write_haiku": - return await handle_write_haiku(arguments) - else: - return types.CallToolResult( - content=[types.TextContent(type="text", text=f"Unknown tool: {name}")], - isError=True, - ) + arguments = params.arguments or {} + + if params.name == "confirm_delete": + return await handle_confirm_delete(ctx, arguments) + elif params.name == "write_haiku": + return await handle_write_haiku(ctx, arguments) + + return types.CallToolResult( + content=[types.TextContent(type="text", text=f"Unknown tool: {params.name}")], + is_error=True, + ) + + +server = Server( + "simple-task-interactive", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, +) + +# Enable task support - this auto-registers all handlers +server.experimental.enable_tasks() def create_app(session_manager: StreamableHTTPSessionManager) -> Starlette: diff --git a/examples/servers/simple-task-interactive/pyproject.toml b/examples/servers/simple-task-interactive/pyproject.toml index 492345ff5..4ec977076 100644 --- a/examples/servers/simple-task-interactive/pyproject.toml +++ b/examples/servers/simple-task-interactive/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "A simple MCP server demonstrating interactive tasks (elicitation & sampling)" readme = "README.md" requires-python = ">=3.10" -authors = [{ name = "Anthropic, PBC." }] +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] keywords = ["mcp", "llm", "tasks", "elicitation", "sampling"] license = { text = "MIT" } classifiers = [ diff --git a/examples/servers/simple-task/mcp_simple_task/server.py b/examples/servers/simple-task/mcp_simple_task/server.py index d0681b842..50ae3ca9a 100644 --- a/examples/servers/simple-task/mcp_simple_task/server.py +++ b/examples/servers/simple-task/mcp_simple_task/server.py @@ -2,66 +2,68 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from typing import Any import anyio import click -import mcp.types as types import uvicorn +from mcp import types +from mcp.server import Server, ServerRequestContext from mcp.server.experimental.task_context import ServerTaskContext -from mcp.server.lowlevel import Server from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from starlette.applications import Starlette from starlette.routing import Mount -server = Server("simple-task-server") -# One-line setup: auto-registers get_task, get_task_result, list_tasks, cancel_task -server.experimental.enable_tasks() +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="long_running_task", + description="A task that takes a few seconds to complete with status updates", + input_schema={"type": "object", "properties": {}}, + execution=types.ToolExecution(task_support=types.TASK_REQUIRED), + ) + ] + ) -@server.list_tools() -async def list_tools() -> list[types.Tool]: - return [ - types.Tool( - name="long_running_task", - description="A task that takes a few seconds to complete with status updates", - inputSchema={"type": "object", "properties": {}}, - execution=types.ToolExecution(taskSupport=types.TASK_REQUIRED), - ) - ] +async def handle_call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams +) -> types.CallToolResult | types.CreateTaskResult: + """Dispatch tool calls to their handlers.""" + if params.name == "long_running_task": + ctx.experimental.validate_task_mode(types.TASK_REQUIRED) + async def work(task: ServerTaskContext) -> types.CallToolResult: + await task.update_status("Starting work...") + await anyio.sleep(1) -async def handle_long_running_task(arguments: dict[str, Any]) -> types.CreateTaskResult: - """Handle the long_running_task tool - demonstrates status updates.""" - ctx = server.request_context - ctx.experimental.validate_task_mode(types.TASK_REQUIRED) + await task.update_status("Processing step 1...") + await anyio.sleep(1) - async def work(task: ServerTaskContext) -> types.CallToolResult: - await task.update_status("Starting work...") - await anyio.sleep(1) + await task.update_status("Processing step 2...") + await anyio.sleep(1) - await task.update_status("Processing step 1...") - await anyio.sleep(1) + return types.CallToolResult(content=[types.TextContent(type="text", text="Task completed!")]) - await task.update_status("Processing step 2...") - await anyio.sleep(1) + return await ctx.experimental.run_task(work) - return types.CallToolResult(content=[types.TextContent(type="text", text="Task completed!")]) + return types.CallToolResult( + content=[types.TextContent(type="text", text=f"Unknown tool: {params.name}")], + is_error=True, + ) - return await ctx.experimental.run_task(work) +server = Server( + "simple-task-server", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, +) -@server.call_tool() -async def handle_call_tool(name: str, arguments: dict[str, Any]) -> types.CallToolResult | types.CreateTaskResult: - """Dispatch tool calls to their handlers.""" - if name == "long_running_task": - return await handle_long_running_task(arguments) - else: - return types.CallToolResult( - content=[types.TextContent(type="text", text=f"Unknown tool: {name}")], - isError=True, - ) +# One-line setup: auto-registers get_task, get_task_result, list_tasks, cancel_task +server.experimental.enable_tasks() @click.command() diff --git a/examples/servers/simple-task/pyproject.toml b/examples/servers/simple-task/pyproject.toml index a8fba8bdc..921a1c34f 100644 --- a/examples/servers/simple-task/pyproject.toml +++ b/examples/servers/simple-task/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "A simple MCP server demonstrating tasks" readme = "README.md" requires-python = ">=3.10" -authors = [{ name = "Anthropic, PBC." }] +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] keywords = ["mcp", "llm", "tasks"] license = { text = "MIT" } classifiers = [ diff --git a/examples/servers/simple-tool/mcp_simple_tool/server.py b/examples/servers/simple-tool/mcp_simple_tool/server.py index 5b2b7d068..9fe71e5b7 100644 --- a/examples/servers/simple-tool/mcp_simple_tool/server.py +++ b/examples/servers/simple-tool/mcp_simple_tool/server.py @@ -1,9 +1,7 @@ -from typing import Any - import anyio import click -import mcp.types as types -from mcp.server.lowlevel import Server +from mcp import types +from mcp.server import Server, ServerRequestContext from mcp.shared._httpx_utils import create_mcp_http_client from starlette.requests import Request @@ -18,33 +16,16 @@ async def fetch_website( return [types.TextContent(type="text", text=response.text)] -@click.command() -@click.option("--port", default=8000, help="Port to listen on for SSE") -@click.option( - "--transport", - type=click.Choice(["stdio", "sse"]), - default="stdio", - help="Transport type", -) -def main(port: int, transport: str) -> int: - app = Server("mcp-website-fetcher") - - @app.call_tool() - async def fetch_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: - if name != "fetch": - raise ValueError(f"Unknown tool: {name}") - if "url" not in arguments: - raise ValueError("Missing required argument 'url'") - return await fetch_website(arguments["url"]) - - @app.list_tools() - async def list_tools() -> list[types.Tool]: - return [ +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", - inputSchema={ + input_schema={ "type": "object", "required": ["url"], "properties": { @@ -56,6 +37,33 @@ async def list_tools() -> list[types.Tool]: }, ) ] + ) + + +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 SSE") +@click.option( + "--transport", + type=click.Choice(["stdio", "sse"]), + 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 == "sse": from mcp.server.sse import SseServerTransport diff --git a/examples/servers/simple-tool/pyproject.toml b/examples/servers/simple-tool/pyproject.toml index c3944f314..022e039e0 100644 --- a/examples/servers/simple-tool/pyproject.toml +++ b/examples/servers/simple-tool/pyproject.toml @@ -4,11 +4,7 @@ version = "0.1.0" description = "A simple MCP server exposing a website fetching tool" readme = "README.md" requires-python = ">=3.10" -authors = [{ name = "Anthropic, PBC." }] -maintainers = [ - { name = "David Soria Parra", email = "davidsp@anthropic.com" }, - { name = "Justin Spahr-Summers", email = "justin@anthropic.com" }, -] +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] keywords = ["mcp", "llm", "automation", "web", "fetch"] license = { text = "MIT" } classifiers = [ 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 index 75f98cdd4..c77bddef3 100644 --- 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 @@ -1,5 +1,4 @@ -""" -In-memory event store for demonstrating resumability functionality. +"""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. @@ -26,8 +25,7 @@ class EventEntry: class InMemoryEventStore(EventStore): - """ - Simple in-memory implementation of the EventStore interface for resumability. + """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. 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 index e4bdcaa39..c8178c35a 100644 --- a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/server.py +++ b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/server.py @@ -1,5 +1,4 @@ -""" -SSE Polling Demo Server +"""SSE Polling Demo Server Demonstrates the SSE polling pattern with close_sse_stream() for long-running tasks. @@ -16,12 +15,11 @@ import contextlib import logging from collections.abc import AsyncIterator -from typing import Any import anyio import click -import mcp.types as types -from mcp.server.lowlevel import Server +from mcp import types +from mcp.server import Server, ServerRequestContext from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from starlette.applications import Starlette from starlette.routing import Mount @@ -32,111 +30,124 @@ logger = logging.getLogger(__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", +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, + }, + }, + }, + ) + ] ) - # Create the lowlevel server - app = Server("sse-polling-demo") - @app.call_tool() - async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: - """Handle tool calls.""" - ctx = app.request_context +async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + """Handle tool calls.""" + arguments = params.arguments or {} - if name == "process_batch": - items = arguments.get("items", 10) - checkpoint_every = arguments.get("checkpoint_every", 3) + 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.TextContent(type="text", text="Error: items must be between 1 and 100")] - if checkpoint_every < 1 or checkpoint_every > 20: - return [types.TextContent(type="text", text="Error: checkpoint_every must be between 1 and 20")] + 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( + 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( level="info", - data=f"Starting batch processing of {items} items...", + data=f"[{i}/{items}] Processing item {i}", 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 + # Checkpoint: close stream to trigger client reconnect + if i % checkpoint_every == 0 and i < items: await ctx.session.send_log_message( level="info", - data=f"[{i}/{items}] Processing item {i}", + data=f"Checkpoint at item {i} - closing SSE stream for polling", 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( - 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 [ + 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.TextContent(type="text", text=f"Unknown tool: {name}")] + return types.CallToolResult(content=[types.TextContent(type="text", text=f"Unknown tool: {params.name}")]) - @app.list_tools() - async def list_tools() -> list[types.Tool]: - """List available tools.""" - return [ - types.Tool( - name="process_batch", - description=( - "Process a batch of items with periodic checkpoints. " - "Demonstrates SSE polling where server closes stream periodically." - ), - inputSchema={ - "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, - }, - }, - }, - ) - ] + +@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, + ) # Create event store for resumability event_store = InMemoryEventStore() diff --git a/examples/servers/sse-polling-demo/pyproject.toml b/examples/servers/sse-polling-demo/pyproject.toml index f7ad89217..400f6580b 100644 --- a/examples/servers/sse-polling-demo/pyproject.toml +++ b/examples/servers/sse-polling-demo/pyproject.toml @@ -4,7 +4,7 @@ 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 = "Anthropic, PBC." }] +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"] 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 index 7f102ff8b..95fb90854 100644 --- a/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__main__.py +++ b/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__main__.py @@ -1,64 +1,55 @@ #!/usr/bin/env python3 -""" -Example low-level MCP server demonstrating structured output support. +"""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, with automatic validation against output -schemas. +structured data from tools. """ import asyncio +import json +import random from datetime import datetime -from typing import Any import mcp.server.stdio -import mcp.types as types -from mcp.server.lowlevel import NotificationOptions, Server -from mcp.server.models import InitializationOptions - -# Create low-level server instance -server = Server("structured-output-lowlevel-example") +from mcp import types +from mcp.server import Server, ServerRequestContext -@server.list_tools() -async def list_tools() -> list[types.Tool]: +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: """List available tools with their schemas.""" - return [ - types.Tool( - name="get_weather", - description="Get weather information (simulated)", - inputSchema={ - "type": "object", - "properties": {"city": {"type": "string", "description": "City name"}}, - "required": ["city"], - }, - outputSchema={ - "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"}, + 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"], }, - "required": ["temperature", "conditions", "humidity", "wind_speed", "timestamp"], - }, - ), - ] - + 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"], + }, + ), + ] + ) -@server.call_tool() -async def call_tool(name: str, arguments: dict[str, Any]) -> Any: - """ - Handle tool call with structured output. - """ - if name == "get_weather": - # city = arguments["city"] # Would be used with real weather API +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) - import random - weather_conditions = ["sunny", "cloudy", "rainy", "partly cloudy", "foggy"] weather_data = { @@ -69,12 +60,19 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any: "timestamp": datetime.now().isoformat(), } - # Return structured data only - # The low-level server will serialize this to JSON content automatically - return weather_data + 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}") - else: - raise ValueError(f"Unknown tool: {name}") + +server = Server( + "structured-output-lowlevel-example", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, +) async def run(): @@ -83,14 +81,7 @@ async def run(): await server.run( read_stream, write_stream, - InitializationOptions( - server_name="structured-output-lowlevel-example", - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), + server.create_initialization_options(), ) diff --git a/examples/snippets/clients/completion_client.py b/examples/snippets/clients/completion_client.py index 8c5615926..dc0c1b4f7 100644 --- a/examples/snippets/clients/completion_client.py +++ b/examples/snippets/clients/completion_client.py @@ -1,6 +1,5 @@ -""" -cd to the `examples/snippets` directory and run: - uv run completion-client +"""cd to the `examples/snippets` directory and run: +uv run completion-client """ import asyncio @@ -28,8 +27,8 @@ async def run(): # List available resource templates templates = await session.list_resource_templates() print("Available resource templates:") - for template in templates.resourceTemplates: - print(f" - {template.uriTemplate}") + for template in templates.resource_templates: + print(f" - {template.uri_template}") # List available prompts prompts = await session.list_prompts() @@ -38,20 +37,20 @@ async def run(): print(f" - {prompt.name}") # Complete resource template arguments - if templates.resourceTemplates: - template = templates.resourceTemplates[0] - print(f"\nCompleting arguments for resource template: {template.uriTemplate}") + 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.uriTemplate), + 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.uriTemplate), + ref=ResourceTemplateReference(type="ref/resource", uri=template.uri_template), argument={"name": "repo", "value": ""}, context_arguments={"owner": "modelcontextprotocol"}, ) diff --git a/examples/snippets/clients/display_utilities.py b/examples/snippets/clients/display_utilities.py index 5f1d50510..baa2765a8 100644 --- a/examples/snippets/clients/display_utilities.py +++ b/examples/snippets/clients/display_utilities.py @@ -1,6 +1,5 @@ -""" -cd to the `examples/snippets` directory and run: - uv run display-utilities-client +"""cd to the `examples/snippets` directory and run: +uv run display-utilities-client """ import asyncio @@ -13,7 +12,7 @@ # Create server parameters for stdio connection server_params = StdioServerParameters( command="uv", # Using uv to run the server - args=["run", "server", "fastmcp_quickstart", "stdio"], + args=["run", "server", "mcpserver_quickstart", "stdio"], env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, ) @@ -39,7 +38,7 @@ async def display_resources(session: ClientSession): print(f"Resource: {display_name} ({resource.uri})") templates_response = await session.list_resource_templates() - for template in templates_response.resourceTemplates: + for template in templates_response.resource_templates: display_name = get_display_name(template) print(f"Resource Template: {display_name}") diff --git a/examples/snippets/clients/oauth_client.py b/examples/snippets/clients/oauth_client.py index 45026590a..3887c5c8c 100644 --- a/examples/snippets/clients/oauth_client.py +++ b/examples/snippets/clients/oauth_client.py @@ -1,5 +1,4 @@ -""" -Before running, specify running MCP RS server URL. +"""Before running, specify running MCP RS server URL. To spin up RS server locally, see examples/servers/simple-auth/README.md @@ -10,11 +9,12 @@ import asyncio from urllib.parse import parse_qs, urlparse +import httpx from pydantic import AnyUrl from mcp import ClientSession from mcp.client.auth import OAuthClientProvider, TokenStorage -from mcp.client.streamable_http import streamablehttp_client +from mcp.client.streamable_http import streamable_http_client from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken @@ -68,15 +68,16 @@ async def main(): callback_handler=handle_callback, ) - async with streamablehttp_client("http://localhost:8001/mcp", auth=oauth_auth) as (read, write, _): - async with ClientSession(read, write) as session: - await session.initialize() + 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]}") + 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]}") + resources = await session.list_resources() + print(f"Available resources: {[r.uri for r in resources.resources]}") def run(): diff --git a/examples/snippets/clients/pagination_client.py b/examples/snippets/clients/pagination_client.py index 1805d2d31..b9b8c23ae 100644 --- a/examples/snippets/clients/pagination_client.py +++ b/examples/snippets/clients/pagination_client.py @@ -1,6 +1,4 @@ -""" -Example of consuming paginated MCP endpoints from a client. -""" +"""Example of consuming paginated MCP endpoints from a client.""" import asyncio @@ -29,8 +27,8 @@ async def list_all_resources() -> None: print(f"Fetched {len(result.resources)} resources") # Check if there are more pages - if result.nextCursor: - cursor = result.nextCursor + if result.next_cursor: + cursor = result.next_cursor else: break diff --git a/examples/snippets/clients/parsing_tool_results.py b/examples/snippets/clients/parsing_tool_results.py index 515873546..b16640677 100644 --- a/examples/snippets/clients/parsing_tool_results.py +++ b/examples/snippets/clients/parsing_tool_results.py @@ -22,9 +22,9 @@ async def parse_tool_results(): # Example 2: Parsing structured content from JSON tools result = await session.call_tool("get_user", {"id": "123"}) - if hasattr(result, "structuredContent") and result.structuredContent: + if hasattr(result, "structured_content") and result.structured_content: # Access structured data directly - user_data = result.structuredContent + user_data = result.structured_content print(f"User: {user_data.get('name')}, Age: {user_data.get('age')}") # Example 3: Parsing embedded resources @@ -41,11 +41,11 @@ async def parse_tool_results(): 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.mimeType}): {len(content.data)} bytes") + print(f"Image ({content.mime_type}): {len(content.data)} bytes") # Example 5: Handling errors result = await session.call_tool("failing_tool", {}) - if result.isError: + if result.is_error: print("Tool execution failed!") for content in result.content: if isinstance(content, types.TextContent): diff --git a/examples/snippets/clients/stdio_client.py b/examples/snippets/clients/stdio_client.py index ac978035d..c1f85f42a 100644 --- a/examples/snippets/clients/stdio_client.py +++ b/examples/snippets/clients/stdio_client.py @@ -1,28 +1,25 @@ -""" -cd to the `examples/snippets/clients` directory and run: - uv run client +"""cd to the `examples/snippets/clients` directory and run: +uv run client """ import asyncio import os -from pydantic import AnyUrl - from mcp import ClientSession, StdioServerParameters, types +from mcp.client.context import ClientRequestContext from mcp.client.stdio import stdio_client -from mcp.shared.context import RequestContext # Create server parameters for stdio connection server_params = StdioServerParameters( command="uv", # Using uv to run the server - args=["run", "server", "fastmcp_quickstart", "stdio"], # We're already in snippets dir + 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: RequestContext[ClientSession, None], params: types.CreateMessageRequestParams + context: ClientRequestContext, params: types.CreateMessageRequestParams ) -> types.CreateMessageResult: print(f"Sampling request: {params.messages}") return types.CreateMessageResult( @@ -32,7 +29,7 @@ async def handle_sampling_message( text="Hello, world! from model", ), model="gpt-3.5-turbo", - stopReason="endTurn", + stop_reason="endTurn", ) @@ -46,7 +43,7 @@ async def run(): prompts = await session.list_prompts() print(f"Available prompts: {[p.name for p in prompts.prompts]}") - # Get a prompt (greet_user prompt from fastmcp_quickstart) + # 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}") @@ -59,18 +56,18 @@ async def run(): tools = await session.list_tools() print(f"Available tools: {[t.name for t in tools.tools]}") - # Read a resource (greeting resource from fastmcp_quickstart) - resource_content = await session.read_resource(AnyUrl("greeting://World")) + # 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.TextContent): print(f"Resource content: {content_block.text}") - # Call a tool (add tool from fastmcp_quickstart) + # 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.structuredContent + result_structured = result.structured_content print(f"Structured tool result: {result_structured}") diff --git a/examples/snippets/clients/streamable_basic.py b/examples/snippets/clients/streamable_basic.py index 108439613..43bb6396c 100644 --- a/examples/snippets/clients/streamable_basic.py +++ b/examples/snippets/clients/streamable_basic.py @@ -1,21 +1,16 @@ -""" -Run from the repository root: - uv run examples/snippets/clients/streamable_basic.py +"""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 streamablehttp_client +from mcp.client.streamable_http import streamable_http_client async def main(): # Connect to a streamable HTTP server - async with streamablehttp_client("http://localhost:8000/mcp") as ( - read_stream, - write_stream, - _, - ): + 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 diff --git a/examples/snippets/clients/url_elicitation_client.py b/examples/snippets/clients/url_elicitation_client.py index 56457512c..2aecbeeee 100644 --- a/examples/snippets/clients/url_elicitation_client.py +++ b/examples/snippets/clients/url_elicitation_client.py @@ -24,21 +24,19 @@ import asyncio import json -import subprocess -import sys import webbrowser from typing import Any from urllib.parse import urlparse from mcp import ClientSession, types +from mcp.client.context import ClientRequestContext from mcp.client.sse import sse_client -from mcp.shared.context import RequestContext -from mcp.shared.exceptions import McpError, UrlElicitationRequiredError +from mcp.shared.exceptions import MCPError, UrlElicitationRequiredError from mcp.types import URL_ELICITATION_REQUIRED async def handle_elicitation( - context: RequestContext[ClientSession, Any], + context: ClientRequestContext, params: types.ElicitRequestParams, ) -> types.ElicitResult | types.ErrorData: """Handle elicitation requests from the server. @@ -56,15 +54,19 @@ async def handle_elicitation( ) +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. Display the full URL and domain for user inspection - 2. Show the server's reason for requesting this interaction - 3. Require explicit user consent before opening any URL + 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) @@ -75,6 +77,12 @@ async def handle_url_elicitation( 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) @@ -105,7 +113,11 @@ async def handle_url_elicitation( # Open the browser print(f"\nOpening browser to: {url}") - open_browser(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)") @@ -121,20 +133,6 @@ def extract_domain(url: str) -> str: return "unknown" -def open_browser(url: str) -> None: - """Open URL in the default browser.""" - try: - if sys.platform == "darwin": - subprocess.run(["open", url], check=False) - elif sys.platform == "win32": - subprocess.run(["start", url], shell=True, check=False) - else: - webbrowser.open(url) - except Exception as e: - print(f"Failed to open browser: {e}") - print(f"Please manually open: {url}") - - async def call_tool_with_error_handling( session: ClientSession, tool_name: str, @@ -154,15 +152,15 @@ async def call_tool_with_error_handling( result = await session.call_tool(tool_name, arguments) # Check if the tool returned an error in the result - if result.isError: + if result.is_error: print(f"Tool returned error: {result.content}") return None return result - except McpError as e: + except MCPError as e: # Check if this is a URL elicitation required error - if e.error.code == URL_ELICITATION_REQUIRED: + if e.code == URL_ELICITATION_REQUIRED: print("\n[Tool requires URL elicitation to proceed]") # Convert to typed error to access elicitations diff --git a/examples/snippets/servers/__init__.py b/examples/snippets/servers/__init__.py index b9865e822..f132f875f 100644 --- a/examples/snippets/servers/__init__.py +++ b/examples/snippets/servers/__init__.py @@ -22,7 +22,7 @@ def run_server(): print("Usage: server <server-name> [transport]") print("Available servers: basic_tool, basic_resource, basic_prompt, tool_progress,") print(" sampling, elicitation, completion, notifications,") - print(" fastmcp_quickstart, structured_output, images") + print(" mcpserver_quickstart, structured_output, images") print("Available transports: stdio (default), sse, streamable-http") sys.exit(1) diff --git a/examples/snippets/servers/basic_prompt.py b/examples/snippets/servers/basic_prompt.py index 40f606ba6..d2eee9729 100644 --- a/examples/snippets/servers/basic_prompt.py +++ b/examples/snippets/servers/basic_prompt.py @@ -1,7 +1,7 @@ -from mcp.server.fastmcp import FastMCP -from mcp.server.fastmcp.prompts import base +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.prompts import base -mcp = FastMCP(name="Prompt Example") +mcp = MCPServer(name="Prompt Example") @mcp.prompt(title="Code Review") diff --git a/examples/snippets/servers/basic_resource.py b/examples/snippets/servers/basic_resource.py index 5c1973059..38d96b491 100644 --- a/examples/snippets/servers/basic_resource.py +++ b/examples/snippets/servers/basic_resource.py @@ -1,6 +1,6 @@ -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer -mcp = FastMCP(name="Resource Example") +mcp = MCPServer(name="Resource Example") @mcp.resource("file://documents/{name}") diff --git a/examples/snippets/servers/basic_tool.py b/examples/snippets/servers/basic_tool.py index 550e24080..7648024bc 100644 --- a/examples/snippets/servers/basic_tool.py +++ b/examples/snippets/servers/basic_tool.py @@ -1,6 +1,6 @@ -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer -mcp = FastMCP(name="Tool Example") +mcp = MCPServer(name="Tool Example") @mcp.tool() diff --git a/examples/snippets/servers/completion.py b/examples/snippets/servers/completion.py index 2a31541dd..47accffa3 100644 --- a/examples/snippets/servers/completion.py +++ b/examples/snippets/servers/completion.py @@ -1,4 +1,4 @@ -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from mcp.types import ( Completion, CompletionArgument, @@ -7,7 +7,7 @@ ResourceTemplateReference, ) -mcp = FastMCP(name="Example") +mcp = MCPServer(name="Example") @mcp.resource("github://repos/{owner}/{repo}") @@ -36,7 +36,7 @@ async def handle_completion( languages = ["python", "javascript", "typescript", "go", "rust"] return Completion( values=[lang for lang in languages if lang.startswith(argument.value)], - hasMore=False, + has_more=False, ) # Complete repository names for GitHub resources @@ -44,6 +44,6 @@ async def handle_completion( if ref.uri == "github://repos/{owner}/{repo}" and argument.name == "repo": if context and context.arguments and context.arguments.get("owner") == "modelcontextprotocol": repos = ["python-sdk", "typescript-sdk", "specification"] - return Completion(values=repos, hasMore=False) + return 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 index 54d49b2f6..4c98c358e 100644 --- a/examples/snippets/servers/direct_call_tool_result.py +++ b/examples/snippets/servers/direct_call_tool_result.py @@ -4,10 +4,10 @@ from pydantic import BaseModel -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from mcp.types import CallToolResult, TextContent -mcp = FastMCP("CallToolResult Example") +mcp = MCPServer("CallToolResult Example") class ValidationModel(BaseModel): @@ -31,7 +31,7 @@ def validated_tool() -> Annotated[CallToolResult, ValidationModel]: """Return CallToolResult with structured output validation.""" return CallToolResult( content=[TextContent(type="text", text="Validated response")], - structuredContent={"status": "success", "data": {"result": 42}}, + structured_content={"status": "success", "data": {"result": 42}}, _meta={"internal": "metadata"}, ) diff --git a/examples/snippets/servers/direct_execution.py b/examples/snippets/servers/direct_execution.py index 65a6fbbf3..acf7151d3 100644 --- a/examples/snippets/servers/direct_execution.py +++ b/examples/snippets/servers/direct_execution.py @@ -7,9 +7,9 @@ python servers/direct_execution.py """ -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer -mcp = FastMCP("My App") +mcp = MCPServer("My App") @mcp.tool() diff --git a/examples/snippets/servers/elicitation.py b/examples/snippets/servers/elicitation.py index a1a65fb32..70e515c75 100644 --- a/examples/snippets/servers/elicitation.py +++ b/examples/snippets/servers/elicitation.py @@ -9,12 +9,12 @@ from pydantic import BaseModel, Field -from mcp.server.fastmcp import Context, FastMCP +from mcp.server.mcpserver import Context, MCPServer from mcp.server.session import ServerSession from mcp.shared.exceptions import UrlElicitationRequiredError from mcp.types import ElicitRequestURLParams -mcp = FastMCP(name="Elicitation Example") +mcp = MCPServer(name="Elicitation Example") class BookingPreferences(BaseModel): @@ -93,7 +93,7 @@ async def connect_service(service_name: str, ctx: Context[ServerSession, None]) mode="url", message=f"Authorization required to connect to {service_name}", url=f"https://{service_name}.example.com/oauth/authorize?elicit={elicitation_id}", - elicitationId=elicitation_id, + elicitation_id=elicitation_id, ) ] ) diff --git a/examples/snippets/servers/images.py b/examples/snippets/servers/images.py index 9e0262c85..b30c9a8f7 100644 --- a/examples/snippets/servers/images.py +++ b/examples/snippets/servers/images.py @@ -1,10 +1,10 @@ -"""Example showing image handling with FastMCP.""" +"""Example showing image handling with MCPServer.""" from PIL import Image as PILImage -from mcp.server.fastmcp import FastMCP, Image +from mcp.server.mcpserver import Image, MCPServer -mcp = FastMCP("Image Example") +mcp = MCPServer("Image Example") @mcp.tool() diff --git a/examples/snippets/servers/lifespan_example.py b/examples/snippets/servers/lifespan_example.py index 62278b6aa..f290d31dd 100644 --- a/examples/snippets/servers/lifespan_example.py +++ b/examples/snippets/servers/lifespan_example.py @@ -4,8 +4,7 @@ from contextlib import asynccontextmanager from dataclasses import dataclass -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession +from mcp.server.mcpserver import Context, MCPServer # Mock database class for example @@ -34,7 +33,7 @@ class AppContext: @asynccontextmanager -async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: +async def app_lifespan(server: MCPServer) -> AsyncIterator[AppContext]: """Manage application lifecycle with type-safe context.""" # Initialize on startup db = await Database.connect() @@ -46,12 +45,12 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: # Pass lifespan to server -mcp = FastMCP("My App", lifespan=app_lifespan) +mcp = MCPServer("My App", lifespan=app_lifespan) # Access type-safe lifespan context in tools @mcp.tool() -def query_db(ctx: Context[ServerSession, AppContext]) -> str: +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/basic.py b/examples/snippets/servers/lowlevel/basic.py index a5c4149df..81f40e994 100644 --- a/examples/snippets/servers/lowlevel/basic.py +++ b/examples/snippets/servers/lowlevel/basic.py @@ -1,38 +1,35 @@ -""" -Run from the repository root: +"""Run from the repository root: uv run examples/snippets/servers/lowlevel/basic.py """ import asyncio import mcp.server.stdio -import mcp.types as types -from mcp.server.lowlevel import NotificationOptions, Server -from mcp.server.models import InitializationOptions - -# Create a server instance -server = Server("example-server") +from mcp import types +from mcp.server import Server, ServerRequestContext -@server.list_prompts() -async def handle_list_prompts() -> list[types.Prompt]: +async def handle_list_prompts( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListPromptsResult: """List available prompts.""" - return [ - types.Prompt( - name="example-prompt", - description="An example prompt template", - arguments=[types.PromptArgument(name="arg1", description="Example argument", required=True)], - ) - ] + return types.ListPromptsResult( + prompts=[ + types.Prompt( + name="example-prompt", + description="An example prompt template", + arguments=[types.PromptArgument(name="arg1", description="Example argument", required=True)], + ) + ] + ) -@server.get_prompt() -async def handle_get_prompt(name: str, arguments: dict[str, str] | None) -> types.GetPromptResult: +async def handle_get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> types.GetPromptResult: """Get a specific prompt by name.""" - if name != "example-prompt": - raise ValueError(f"Unknown prompt: {name}") + if params.name != "example-prompt": + raise ValueError(f"Unknown prompt: {params.name}") - arg1_value = (arguments or {}).get("arg1", "default") + arg1_value = (params.arguments or {}).get("arg1", "default") return types.GetPromptResult( description="Example prompt", @@ -45,20 +42,20 @@ async def handle_get_prompt(name: str, arguments: dict[str, str] | None) -> type ) +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, - InitializationOptions( - server_name="example", - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), + server.create_initialization_options(), ) diff --git a/examples/snippets/servers/lowlevel/direct_call_tool_result.py b/examples/snippets/servers/lowlevel/direct_call_tool_result.py index 496eaad10..7e8fc4dcb 100644 --- a/examples/snippets/servers/lowlevel/direct_call_tool_result.py +++ b/examples/snippets/servers/lowlevel/direct_call_tool_result.py @@ -1,47 +1,51 @@ -""" -Run from the repository root: - uv run examples/snippets/servers/lowlevel/direct_call_tool_result.py +"""Run from the repository root: +uv run examples/snippets/servers/lowlevel/direct_call_tool_result.py """ import asyncio -from typing import Any import mcp.server.stdio -import mcp.types as types -from mcp.server.lowlevel import NotificationOptions, Server -from mcp.server.models import InitializationOptions - -server = Server("example-server") +from mcp import types +from mcp.server import Server, ServerRequestContext -@server.list_tools() -async def list_tools() -> list[types.Tool]: +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: """List available tools.""" - return [ - types.Tool( - name="advanced_tool", - description="Tool with full control including _meta field", - inputSchema={ - "type": "object", - "properties": {"message": {"type": "string"}}, - "required": ["message"], - }, - ) - ] - - -@server.call_tool() -async def handle_call_tool(name: str, arguments: dict[str, Any]) -> types.CallToolResult: + 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 name == "advanced_tool": - message = str(arguments.get("message", "")) + if params.name == "advanced_tool": + message = (params.arguments or {}).get("message", "") return types.CallToolResult( content=[types.TextContent(type="text", text=f"Processed: {message}")], - structuredContent={"result": "success", "message": message}, + structured_content={"result": "success", "message": message}, _meta={"hidden": "data for client applications only"}, ) - raise ValueError(f"Unknown tool: {name}") + 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(): @@ -50,14 +54,7 @@ async def run(): await server.run( read_stream, write_stream, - InitializationOptions( - server_name="example", - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), + server.create_initialization_options(), ) diff --git a/examples/snippets/servers/lowlevel/lifespan.py b/examples/snippets/servers/lowlevel/lifespan.py index ada373122..bcd96c893 100644 --- a/examples/snippets/servers/lowlevel/lifespan.py +++ b/examples/snippets/servers/lowlevel/lifespan.py @@ -1,16 +1,14 @@ -""" -Run from the repository root: - uv run examples/snippets/servers/lowlevel/lifespan.py +"""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 Any +from typing import TypedDict import mcp.server.stdio -import mcp.types as types -from mcp.server.lowlevel import NotificationOptions, Server -from mcp.server.models import InitializationOptions +from mcp import types +from mcp.server import Server, ServerRequestContext # Mock database class for example @@ -33,52 +31,58 @@ async def query(self, query_str: str) -> list[dict[str, str]]: return [{"id": "1", "name": "Example", "query": query_str}] +class AppContext(TypedDict): + db: Database + + @asynccontextmanager -async def server_lifespan(_server: Server) -> AsyncIterator[dict[str, Any]]: +async def server_lifespan(_server: Server[AppContext]) -> AsyncIterator[AppContext]: """Manage server startup and shutdown lifecycle.""" - # Initialize resources on startup db = await Database.connect() try: yield {"db": db} finally: - # Clean up on shutdown await db.disconnect() -# Pass lifespan to server -server = Server("example-server", lifespan=server_lifespan) - - -@server.list_tools() -async def handle_list_tools() -> list[types.Tool]: +async def handle_list_tools( + ctx: ServerRequestContext[AppContext], params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: """List available tools.""" - return [ - types.Tool( - name="query_db", - description="Query the database", - inputSchema={ - "type": "object", - "properties": {"query": {"type": "string", "description": "SQL query to execute"}}, - "required": ["query"], - }, - ) - ] - - -@server.call_tool() -async def query_db(name: str, arguments: dict[str, Any]) -> list[types.TextContent]: + 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 name != "query_db": - raise ValueError(f"Unknown tool: {name}") + if params.name != "query_db": + raise ValueError(f"Unknown tool: {params.name}") - # Access lifespan context - ctx = server.request_context 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}")]) - # Execute query - results = await db.query(arguments["query"]) - return [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(): @@ -87,14 +91,7 @@ async def run(): await server.run( read_stream, write_stream, - InitializationOptions( - server_name="example-server", - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), + server.create_initialization_options(), ) diff --git a/examples/snippets/servers/lowlevel/structured_output.py b/examples/snippets/servers/lowlevel/structured_output.py index 0237c9ab3..f93c8875f 100644 --- a/examples/snippets/servers/lowlevel/structured_output.py +++ b/examples/snippets/servers/lowlevel/structured_output.py @@ -1,65 +1,69 @@ -""" -Run from the repository root: - uv run examples/snippets/servers/lowlevel/structured_output.py +"""Run from the repository root: +uv run examples/snippets/servers/lowlevel/structured_output.py """ import asyncio -from typing import Any +import json import mcp.server.stdio -import mcp.types as types -from mcp.server.lowlevel import NotificationOptions, Server -from mcp.server.models import InitializationOptions - -server = Server("example-server") +from mcp import types +from mcp.server import Server, ServerRequestContext -@server.list_tools() -async def list_tools() -> list[types.Tool]: +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: """List available tools with structured output schemas.""" - return [ - types.Tool( - name="get_weather", - description="Get current weather for a city", - inputSchema={ - "type": "object", - "properties": {"city": {"type": "string", "description": "City name"}}, - "required": ["city"], - }, - outputSchema={ - "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"}, + 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"], }, - "required": ["temperature", "condition", "humidity", "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"], + }, + ) + ] + ) -@server.call_tool() -async def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]: +async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: """Handle tool calls with structured output.""" - if name == "get_weather": - city = arguments["city"] + if params.name == "get_weather": + city = (params.arguments or {})["city"] - # Simulated weather data - in production, call a weather API weather_data = { "temperature": 22.5, "condition": "partly cloudy", "humidity": 65, - "city": city, # Include the requested city + "city": city, } - # low-level server will validate structured output against the tool's - # output schema, and additionally serialize it into a TextContent block - # for backwards compatibility with pre-2025-06-18 clients. - return weather_data - else: - raise ValueError(f"Unknown tool: {name}") + 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(): @@ -68,14 +72,7 @@ async def run(): await server.run( read_stream, write_stream, - InitializationOptions( - server_name="structured-output-example", - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), + server.create_initialization_options(), ) diff --git a/examples/snippets/servers/fastmcp_quickstart.py b/examples/snippets/servers/mcpserver_quickstart.py similarity index 78% rename from examples/snippets/servers/fastmcp_quickstart.py rename to examples/snippets/servers/mcpserver_quickstart.py index 931cd263f..70a83a56e 100644 --- a/examples/snippets/servers/fastmcp_quickstart.py +++ b/examples/snippets/servers/mcpserver_quickstart.py @@ -1,14 +1,13 @@ -""" -FastMCP quickstart example. +"""MCPServer quickstart example. Run from the repository root: - uv run examples/snippets/servers/fastmcp_quickstart.py + uv run examples/snippets/servers/mcpserver_quickstart.py """ -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer # Create an MCP server -mcp = FastMCP("Demo", json_response=True) +mcp = MCPServer("Demo") # Add an addition tool @@ -40,4 +39,4 @@ def greet_user(name: str, style: str = "friendly") -> str: # Run with streamable HTTP transport if __name__ == "__main__": - mcp.run(transport="streamable-http") + mcp.run(transport="streamable-http", json_response=True) diff --git a/examples/snippets/servers/notifications.py b/examples/snippets/servers/notifications.py index 833bc8905..5579af510 100644 --- a/examples/snippets/servers/notifications.py +++ b/examples/snippets/servers/notifications.py @@ -1,7 +1,7 @@ -from mcp.server.fastmcp import Context, FastMCP +from mcp.server.mcpserver import Context, MCPServer from mcp.server.session import ServerSession -mcp = FastMCP(name="Notifications Example") +mcp = MCPServer(name="Notifications Example") @mcp.tool() diff --git a/examples/snippets/servers/oauth_server.py b/examples/snippets/servers/oauth_server.py index 3717c66de..962ef0615 100644 --- a/examples/snippets/servers/oauth_server.py +++ b/examples/snippets/servers/oauth_server.py @@ -1,13 +1,12 @@ -""" -Run from the repository root: - uv run examples/snippets/servers/oauth_server.py +"""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.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer class SimpleTokenVerifier(TokenVerifier): @@ -17,10 +16,9 @@ async def verify_token(self, token: str) -> AccessToken | None: pass # This is where you would implement actual token validation -# Create FastMCP instance as a Resource Server -mcp = FastMCP( +# Create MCPServer instance as a Resource Server +mcp = MCPServer( "Weather Service", - json_response=True, # Token verifier for authentication token_verifier=SimpleTokenVerifier(), # Auth settings for RFC 9728 Protected Resource Metadata @@ -44,4 +42,4 @@ async def get_weather(city: str = "London") -> dict[str, str]: if __name__ == "__main__": - mcp.run(transport="streamable-http") + mcp.run(transport="streamable-http", json_response=True) diff --git a/examples/snippets/servers/pagination_example.py b/examples/snippets/servers/pagination_example.py index 70c3b3492..bcd0ffb10 100644 --- a/examples/snippets/servers/pagination_example.py +++ b/examples/snippets/servers/pagination_example.py @@ -1,26 +1,20 @@ -""" -Example of implementing pagination with MCP server decorators. -""" +"""Example of implementing pagination with the low-level MCP server.""" -from pydantic import AnyUrl - -import mcp.types as types -from mcp.server.lowlevel import Server - -# Initialize the server -server = Server("paginated-server") +from mcp import types +from mcp.server import Server, ServerRequestContext # Sample data to paginate ITEMS = [f"Item {i}" for i in range(1, 101)] # 100 items -@server.list_resources() -async def list_resources_paginated(request: types.ListResourcesRequest) -> types.ListResourcesResult: +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 = request.params.cursor if request.params is not None else None + cursor = params.cursor if params is not None else None # Parse cursor to get offset start = 0 if cursor is None else int(cursor) @@ -28,11 +22,14 @@ async def list_resources_paginated(request: types.ListResourcesRequest) -> types # Get page of resources page_items = [ - types.Resource(uri=AnyUrl(f"resource://items/{item}"), name=item, description=f"Description for {item}") + 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, nextCursor=next_cursor) + 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 index ae78a74ac..4ffeeda72 100644 --- a/examples/snippets/servers/sampling.py +++ b/examples/snippets/servers/sampling.py @@ -1,8 +1,8 @@ -from mcp.server.fastmcp import Context, FastMCP +from mcp.server.mcpserver import Context, MCPServer from mcp.server.session import ServerSession from mcp.types import SamplingMessage, TextContent -mcp = FastMCP(name="Sampling Example") +mcp = MCPServer(name="Sampling Example") @mcp.tool() diff --git a/examples/snippets/servers/streamable_config.py b/examples/snippets/servers/streamable_config.py index d351a45d8..622e67063 100644 --- a/examples/snippets/servers/streamable_config.py +++ b/examples/snippets/servers/streamable_config.py @@ -1,19 +1,10 @@ +"""Run from the repository root: +uv run examples/snippets/servers/streamable_config.py """ -Run from the repository root: - uv run examples/snippets/servers/streamable_config.py -""" - -from mcp.server.fastmcp import FastMCP - -# Stateless server with JSON responses (recommended) -mcp = FastMCP("StatelessServer", stateless_http=True, json_response=True) -# Other configuration options: -# Stateless server with SSE streaming responses -# mcp = FastMCP("StatelessServer", stateless_http=True) +from mcp.server.mcpserver import MCPServer -# Stateful server with session persistence -# mcp = FastMCP("StatefulServer") +mcp = MCPServer("StatelessServer") # Add a simple tool to demonstrate the server @@ -24,5 +15,14 @@ def greet(name: str = "World") -> str: # Run server with streamable_http transport +# Transport-specific options (stateless_http, json_response) are passed to run() if __name__ == "__main__": - mcp.run(transport="streamable-http") + # 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 index 74aa36ed4..9a53034f1 100644 --- a/examples/snippets/servers/streamable_http_basic_mounting.py +++ b/examples/snippets/servers/streamable_http_basic_mounting.py @@ -1,5 +1,4 @@ -""" -Basic example showing how to mount StreamableHTTP server in Starlette. +"""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 @@ -10,10 +9,10 @@ from starlette.applications import Starlette from starlette.routing import Mount -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer # Create MCP server -mcp = FastMCP("My App", json_response=True) +mcp = MCPServer("My App") @mcp.tool() @@ -30,9 +29,10 @@ async def lifespan(app: Starlette): # 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()), + 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 index 3ae9d341e..2a41f74a5 100644 --- a/examples/snippets/servers/streamable_http_host_mounting.py +++ b/examples/snippets/servers/streamable_http_host_mounting.py @@ -1,5 +1,4 @@ -""" -Example showing how to mount StreamableHTTP server using Host-based routing. +"""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 @@ -10,10 +9,10 @@ from starlette.applications import Starlette from starlette.routing import Host -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer # Create MCP server -mcp = FastMCP("MCP Host App", json_response=True) +mcp = MCPServer("MCP Host App") @mcp.tool() @@ -30,9 +29,10 @@ async def lifespan(app: Starlette): # 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()), + 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 index 8d0a1018d..71217bdfe 100644 --- a/examples/snippets/servers/streamable_http_multiple_servers.py +++ b/examples/snippets/servers/streamable_http_multiple_servers.py @@ -1,5 +1,4 @@ -""" -Example showing how to mount multiple StreamableHTTP servers with path configuration. +"""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 @@ -10,11 +9,11 @@ from starlette.applications import Starlette from starlette.routing import Mount -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer # Create multiple MCP servers -api_mcp = FastMCP("API Server", json_response=True) -chat_mcp = FastMCP("Chat Server", json_response=True) +api_mcp = MCPServer("API Server") +chat_mcp = MCPServer("Chat Server") @api_mcp.tool() @@ -29,12 +28,6 @@ def send_message(message: str) -> str: return f"Message sent: {message}" -# Configure servers to mount at the root of each path -# This means endpoints will be at /api and /chat instead of /api/mcp and /chat/mcp -api_mcp.settings.streamable_http_path = "/" -chat_mcp.settings.streamable_http_path = "/" - - # Create a combined lifespan to manage both session managers @contextlib.asynccontextmanager async def lifespan(app: Starlette): @@ -44,11 +37,12 @@ async def lifespan(app: Starlette): yield -# Mount the servers +# 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()), - Mount("/chat", app=chat_mcp.streamable_http_app()), + 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 index 9fabf12fa..4c65ffdd7 100644 --- a/examples/snippets/servers/streamable_http_path_config.py +++ b/examples/snippets/servers/streamable_http_path_config.py @@ -1,5 +1,4 @@ -""" -Example showing path configuration during FastMCP initialization. +"""Example showing path configuration when mounting MCPServer. Run from the repository root: uvicorn examples.snippets.servers.streamable_http_path_config:app --reload @@ -8,15 +7,10 @@ from starlette.applications import Starlette from starlette.routing import Mount -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer -# Configure streamable_http_path during initialization -# This server will mount at the root of wherever it's mounted -mcp_at_root = FastMCP( - "My Server", - json_response=True, - streamable_http_path="/", -) +# Create a simple MCPServer server +mcp_at_root = MCPServer("My Server") @mcp_at_root.tool() @@ -25,9 +19,13 @@ def process_data(data: str) -> str: return f"Processed: {data}" -# Mount at /process - endpoints will be at /process instead of /process/mcp +# 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()), + 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 index b3a630b0f..eb6f1b809 100644 --- a/examples/snippets/servers/streamable_starlette_mount.py +++ b/examples/snippets/servers/streamable_starlette_mount.py @@ -1,6 +1,5 @@ -""" -Run from the repository root: - uvicorn examples.snippets.servers.streamable_starlette_mount:app --reload +"""Run from the repository root: +uvicorn examples.snippets.servers.streamable_starlette_mount:app --reload """ import contextlib @@ -8,10 +7,10 @@ from starlette.applications import Starlette from starlette.routing import Mount -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer # Create the Echo server -echo_mcp = FastMCP(name="EchoServer", stateless_http=True, json_response=True) +echo_mcp = MCPServer(name="EchoServer") @echo_mcp.tool() @@ -21,7 +20,7 @@ def echo(message: str) -> str: # Create the Math server -math_mcp = FastMCP(name="MathServer", stateless_http=True, json_response=True) +math_mcp = MCPServer(name="MathServer") @math_mcp.tool() @@ -42,13 +41,13 @@ async def lifespan(app: Starlette): # Create the Starlette app and mount the MCP servers app = Starlette( routes=[ - Mount("/echo", echo_mcp.streamable_http_app()), - Mount("/math", math_mcp.streamable_http_app()), + 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.settings.streamable_http_path = "/" -# math_mcp.settings.streamable_http_path = "/" +# 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 index 50ee130c7..bea7b22c1 100644 --- a/examples/snippets/servers/structured_output.py +++ b/examples/snippets/servers/structured_output.py @@ -4,9 +4,9 @@ from pydantic import BaseModel, Field -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer -mcp = FastMCP("Structured Output Example") +mcp = MCPServer("Structured Output Example") # Using Pydantic models for rich structured data diff --git a/examples/snippets/servers/tool_progress.py b/examples/snippets/servers/tool_progress.py index 2ac458f6a..0b283cb1f 100644 --- a/examples/snippets/servers/tool_progress.py +++ b/examples/snippets/servers/tool_progress.py @@ -1,7 +1,7 @@ -from mcp.server.fastmcp import Context, FastMCP +from mcp.server.mcpserver import Context, MCPServer from mcp.server.session import ServerSession -mcp = FastMCP(name="Progress Example") +mcp = MCPServer(name="Progress Example") @mcp.tool() diff --git a/mkdocs.yml b/mkdocs.yml index 22c323d9d..070c533e3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,6 +13,7 @@ site_url: https://modelcontextprotocol.github.io/python-sdk nav: - Introduction: index.md - Installation: installation.md + - Migration Guide: migration.md - Documentation: - Concepts: concepts.md - Low-Level Server: low-level-server.md @@ -111,7 +112,8 @@ watch: plugins: - search - - social + - social: + enabled: !ENV [ENABLE_SOCIAL_CARDS, false] - glightbox - mkdocstrings: handlers: @@ -126,7 +128,7 @@ plugins: group_by_category: false # 3 because docs are in pages with an H2 just above them heading_level: 3 - import: + 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 index 078a1dfdc..737839a23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,12 @@ dynamic = ["version"] description = "Model Context Protocol SDK" readme = "README.md" requires-python = ">=3.10" -authors = [{ name = "Anthropic, PBC." }] +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] maintainers = [ { name = "David Soria Parra", email = "davidsp@anthropic.com" }, - { name = "Justin Spahr-Summers", email = "justin@anthropic.com" }, + { name = "Marcelo Trylesinski", email = "marcelotryle@gmail.com" }, + { name = "Max Isbey", email = "maxisbey@anthropic.com" }, + { name = "Felix Weinberger", email = "fweinberger@anthropic.com" }, ] keywords = ["git", "mcp", "llm", "automation"] license = { text = "MIT" } @@ -20,21 +22,23 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] dependencies = [ "anyio>=4.5", "httpx>=0.27.1", "httpx-sse>=0.4", - "pydantic>=2.11.0,<3.0.0", - "starlette>=0.27", + "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>=1.6.1", + "sse-starlette>=3.0.0", "pydantic-settings>=2.5.2", "uvicorn>=0.31.1; sys_platform != 'emscripten'", "jsonschema>=4.20.0", - "pywin32>=310; sys_platform == 'win32'", + "pywin32>=311; sys_platform == 'win32'", "pyjwt[crypto]>=2.10.1", - "typing-extensions>=4.9.0", + "typing-extensions>=4.13.0", "typing-inspection>=0.4.1", ] @@ -52,6 +56,8 @@ required-version = ">=0.9.5" [dependency-groups] dev = [ + # We add mcp[cli,ws] so `uv sync` considers the extras. + "mcp[cli,ws]", "pyright>=1.1.400", "pytest>=8.3.4", "ruff>=0.8.5", @@ -62,13 +68,15 @@ dev = [ "pytest-pretty>=1.2.0", "inline-snapshot>=0.23.0", "dirty-equals>=0.9.0", - "coverage[toml]==7.10.7", + "coverage[toml]>=7.10.7,<=7.13", + "pillow>=12.0", + "strict-no-cover", ] docs = [ "mkdocs>=1.6.1", "mkdocs-glightbox>=0.4.0", "mkdocs-material[imaging]>=9.5.45", - "mkdocstrings-python>=1.12.2", + "mkdocstrings-python>=2.0.1", ] [build-system] @@ -93,7 +101,13 @@ packages = ["src/mcp"] [tool.pyright] typeCheckingMode = "strict" -include = ["src/mcp", "tests", "examples/servers", "examples/snippets"] +include = [ + "src/mcp", + "tests", + "examples/servers", + "examples/snippets", + "examples/clients", +] venvPath = "." venv = ".venv" # The FastAPI style of using decorators in tests gives a `reportUnusedFunction` error. @@ -102,32 +116,41 @@ venv = ".venv" # 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 = ["."], reportUnusedFunction = false, reportPrivateUsage = false }, + { root = "tests", extraPaths = [ + ".", + ], reportUnusedFunction = false, reportPrivateUsage = false }, { root = "examples/servers", reportUnusedFunction = false }, ] [tool.ruff] line-length = 120 target-version = "py310" -extend-exclude = ["README.md"] +extend-exclude = ["README.md", "README.v2.md"] [tool.ruff.lint] select = [ - "C4", # flake8-comprehensions - "C90", # mccabe - "E", # pycodestyle - "F", # pyflakes - "I", # isort - "PERF", # Perflint - "PL", # Pylint - "UP", # pyupgrade + "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", "PLC0415", "PLR0402"] -mccabe.max-complexity = 24 # Default is 10 +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"] -"tests/server/fastmcp/test_func_metadata.py" = ["E501"] +"tests/server/mcpserver/test_func_metadata.py" = ["E501"] "tests/shared/test_progress_notifications.py" = ["PLW0603"] [tool.ruff.lint.pylint] @@ -142,6 +165,7 @@ members = ["examples/clients/*", "examples/servers/*", "examples/snippets"] [tool.uv.sources] mcp = { workspace = true } +strict-no-cover = { git = "https://github.com/pydantic/strict-no-cover" } [tool.pytest.ini_options] log_cli = true @@ -149,7 +173,8 @@ xfail_strict = true addopts = """ --color=yes --capture=fd - --numprocesses auto + -p anyio + -p examples """ filterwarnings = [ "error", @@ -178,7 +203,6 @@ branch = true patch = ["subprocess"] concurrency = ["multiprocessing", "thread"] source = ["src", "tests"] -relative_files = true omit = [ "src/mcp/client/__main__.py", "src/mcp/server/__main__.py", @@ -195,6 +219,7 @@ ignore_errors = true precision = 2 exclude_lines = [ "pragma: no cover", + "pragma: lax no cover", "if TYPE_CHECKING:", "@overload", "raise NotImplementedError", @@ -208,3 +233,7 @@ source = [ "/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/scripts/update_readme_snippets.py b/scripts/update_readme_snippets.py index d325333ff..8a534e5cb 100755 --- a/scripts/update_readme_snippets.py +++ b/scripts/update_readme_snippets.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -Update README.md with live code snippets from example files. +"""Update README.md with live code snippets from example files. This script finds specially marked code blocks in README.md and updates them with the actual code from the referenced files. @@ -145,7 +144,8 @@ def main(): parser.add_argument( "--check", action="store_true", help="Check mode - verify snippets are up to date without modifying" ) - parser.add_argument("--readme", default="README.md", help="Path to README file (default: README.md)") + # TODO(v2): Drop the `--readme` argument when v2 is released, and set to `README.md`. + parser.add_argument("--readme", default="README.v2.md", help="Path to README file (default: README.v2.md)") args = parser.parse_args() diff --git a/src/mcp/__init__.py b/src/mcp/__init__.py index fbec40d0a..4b5caa9cc 100644 --- a/src/mcp/__init__.py +++ b/src/mcp/__init__.py @@ -1,9 +1,10 @@ +from .client.client import Client from .client.session import ClientSession from .client.session_group import ClientSessionGroup from .client.stdio import StdioServerParameters, stdio_client from .server.session import ServerSession from .server.stdio import stdio_server -from .shared.exceptions import McpError, UrlElicitationRequiredError +from .shared.exceptions import MCPError, UrlElicitationRequiredError from .types import ( CallToolRequest, ClientCapabilities, @@ -62,12 +63,11 @@ ToolUseContent, UnsubscribeRequest, ) -from .types import ( - Role as SamplingRole, -) +from .types import Role as SamplingRole __all__ = [ "CallToolRequest", + "Client", "ClientCapabilities", "ClientNotification", "ClientRequest", @@ -96,7 +96,7 @@ "ListToolsResult", "LoggingLevel", "LoggingMessageNotification", - "McpError", + "MCPError", "Notification", "PingRequest", "ProgressNotification", diff --git a/src/mcp/cli/__init__.py b/src/mcp/cli/__init__.py index b29bce887..daf07c0a1 100644 --- a/src/mcp/cli/__init__.py +++ b/src/mcp/cli/__init__.py @@ -1,4 +1,4 @@ -"""FastMCP CLI package.""" +"""MCP CLI package.""" from .cli import app diff --git a/src/mcp/cli/claude.py b/src/mcp/cli/claude.py index f2dc6888a..071b4b6fb 100644 --- a/src/mcp/cli/claude.py +++ b/src/mcp/cli/claude.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Any -from mcp.server.fastmcp.utilities.logging import get_logger +from mcp.server.mcpserver.utilities.logging import get_logger logger = get_logger(__name__) @@ -49,7 +49,7 @@ def update_claude_config( with_packages: list[str] | None = None, env_vars: dict[str, str] | None = None, ) -> bool: - """Add or update a FastMCP server in Claude's configuration. + """Add or update an MCP server in Claude's configuration. Args: file_spec: Path to the server file, optionally with :object suffix @@ -72,7 +72,7 @@ def update_claude_config( ) config_file = config_dir / "claude_desktop_config.json" - if not config_file.exists(): # pragma: no cover + if not config_file.exists(): # pragma: lax no cover try: config_file.write_text("{}") except Exception: @@ -121,7 +121,7 @@ def update_claude_config( else: # pragma: no cover file_spec = str(Path(file_spec).resolve()) - # Add fastmcp run command + # Add mcp run command args.extend(["mcp", "run", file_spec]) server_config: dict[str, Any] = {"command": uv_path, "args": args} diff --git a/src/mcp/cli/cli.py b/src/mcp/cli/cli.py index c4cae0dce..62334a4a2 100644 --- a/src/mcp/cli/cli.py +++ b/src/mcp/cli/cli.py @@ -8,7 +8,7 @@ from pathlib import Path from typing import Annotated, Any -from mcp.server import FastMCP +from mcp.server import MCPServer from mcp.server import Server as LowLevelServer try: @@ -19,9 +19,9 @@ try: from mcp.cli import claude - from mcp.server.fastmcp.utilities.logging import get_logger + from mcp.server.mcpserver.utilities.logging import get_logger except ImportError: # pragma: no cover - print("Error: mcp.server.fastmcp is not installed or not in PYTHONPATH") + print("Error: mcp.server is not installed or not in PYTHONPATH") sys.exit(1) try: @@ -77,7 +77,7 @@ def _build_uv_command( if with_packages: for pkg in with_packages: - if pkg: # pragma: no cover + if pkg: # pragma: no branch cmd.extend(["--with", pkg]) # Add mcp run command @@ -149,12 +149,10 @@ def _check_server_object(server_object: Any, object_name: str): Returns: True if it's supported. """ - if not isinstance(server_object, FastMCP): - logger.error(f"The server object {object_name} is of type {type(server_object)} (expecting {FastMCP}).") + if not isinstance(server_object, MCPServer): + logger.error(f"The server object {object_name} is of type {type(server_object)} (expecting {MCPServer}).") if isinstance(server_object, LowLevelServer): - logger.warning( - "Note that only FastMCP server is supported. Low level Server class is not yet supported." - ) + logger.warning("Note that only MCPServer is supported. Low level Server class is not yet supported.") return False return True @@ -172,8 +170,8 @@ def _check_server_object(server_object: Any, object_name: str): f"No server object found in {file}. Please either:\n" "1. Use a standard variable name (mcp, server, or app)\n" "2. Specify the object name with file:object syntax" - "3. If the server creates the FastMCP object within main() " - " or another function, refactor the FastMCP object to be a " + "3. If the server creates the MCPServer object within main() " + " or another function, refactor the MCPServer object to be a " " global variable named mcp, server, or app.", extra={"file": str(file)}, ) @@ -319,12 +317,12 @@ def run( ) -> None: # pragma: no cover """Run an MCP server. - The server can be specified in two ways:\n - 1. Module approach: server.py - runs the module directly, expecting a server.run() call.\n - 2. Import approach: server.py:app - imports and runs the specified server object.\n\n + The server can be specified in two ways: + 1. Module approach: server.py - runs the module directly, expecting a server.run() call. + 2. Import approach: server.py:app - imports and runs the specified server object. Note: This command runs the server directly. You are responsible for ensuring - all dependencies are available.\n + all dependencies are available. For dependency management, use `mcp install` or `mcp dev` instead. """ # noqa: E501 file, server_object = _parse_file_path(file_spec) diff --git a/src/mcp/client/__init__.py b/src/mcp/client/__init__.py index e69de29bb..59bce03b8 100644 --- a/src/mcp/client/__init__.py +++ b/src/mcp/client/__init__.py @@ -0,0 +1,8 @@ +"""MCP Client module.""" + +from mcp.client._transport import Transport +from mcp.client.client import Client +from mcp.client.context import ClientRequestContext +from mcp.client.session import ClientSession + +__all__ = ["Client", "ClientRequestContext", "ClientSession", "Transport"] diff --git a/src/mcp/client/__main__.py b/src/mcp/client/__main__.py index 2efe05d53..f3db17906 100644 --- a/src/mcp/client/__main__.py +++ b/src/mcp/client/__main__.py @@ -1,13 +1,14 @@ import argparse import logging import sys +import warnings from functools import partial from urllib.parse import urlparse import anyio from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -import mcp.types as types +from mcp import types from mcp.client.session import ClientSession from mcp.client.sse import sse_client from mcp.client.stdio import StdioServerParameters, stdio_client @@ -15,8 +16,6 @@ from mcp.shared.session import RequestResponder if not sys.warnoptions: - import warnings - warnings.simplefilter("ignore") logging.basicConfig(level=logging.INFO) diff --git a/src/mcp/client/_memory.py b/src/mcp/client/_memory.py new file mode 100644 index 000000000..e6e938673 --- /dev/null +++ b/src/mcp/client/_memory.py @@ -0,0 +1,78 @@ +"""In-memory transport for testing MCP servers without network overhead.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from types import TracebackType +from typing import Any + +import anyio + +from mcp.client._transport import TransportStreams +from mcp.server import Server +from mcp.server.mcpserver import MCPServer +from mcp.shared.memory import create_client_server_memory_streams + + +class InMemoryTransport: + """In-memory transport for testing MCP servers without network overhead. + + This transport starts the server in a background task and provides + streams for client-side communication. The server is automatically + stopped when the context manager exits. + """ + + def __init__(self, server: Server[Any] | MCPServer, *, raise_exceptions: bool = False) -> None: + """Initialize the in-memory transport. + + Args: + server: The MCP server to connect to (Server or MCPServer instance) + raise_exceptions: Whether to raise exceptions from the server + """ + self._server = server + self._raise_exceptions = raise_exceptions + self._cm: AbstractAsyncContextManager[TransportStreams] | None = None + + @asynccontextmanager + async def _connect(self) -> AsyncIterator[TransportStreams]: + """Connect to the server and yield streams for communication.""" + # Unwrap MCPServer to get underlying Server + if isinstance(self._server, MCPServer): + # TODO(Marcelo): Make `lowlevel_server` public. + actual_server: Server[Any] = self._server._lowlevel_server # type: ignore[reportPrivateUsage] + else: + actual_server = self._server + + async with create_client_server_memory_streams() as (client_streams, server_streams): + client_read, client_write = client_streams + server_read, server_write = server_streams + + async with anyio.create_task_group() as tg: + # Start server in background + tg.start_soon( + lambda: actual_server.run( + server_read, + server_write, + actual_server.create_initialization_options(), + raise_exceptions=self._raise_exceptions, + ) + ) + + try: + yield client_read, client_write + finally: + tg.cancel_scope.cancel() + + async def __aenter__(self) -> TransportStreams: + """Connect to the server and return streams for communication.""" + self._cm = self._connect() + return await self._cm.__aenter__() + + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None + ) -> None: + """Close the transport and stop the server.""" + if self._cm is not None: # pragma: no branch + await self._cm.__aexit__(exc_type, exc_val, exc_tb) + self._cm = None diff --git a/src/mcp/client/_transport.py b/src/mcp/client/_transport.py new file mode 100644 index 000000000..a86362900 --- /dev/null +++ b/src/mcp/client/_transport.py @@ -0,0 +1,20 @@ +"""Transport protocol for MCP clients.""" + +from __future__ import annotations + +from contextlib import AbstractAsyncContextManager +from typing import Protocol + +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream + +from mcp.shared.message import SessionMessage + +TransportStreams = tuple[MemoryObjectReceiveStream[SessionMessage | Exception], MemoryObjectSendStream[SessionMessage]] + + +class Transport(AbstractAsyncContextManager[TransportStreams], Protocol): + """Protocol for MCP transports. + + A transport is an async context manager that yields read and write streams + for bidirectional communication with an MCP server. + """ diff --git a/src/mcp/client/auth/__init__.py b/src/mcp/client/auth/__init__.py index 252dfd9e4..ab3179ecb 100644 --- a/src/mcp/client/auth/__init__.py +++ b/src/mcp/client/auth/__init__.py @@ -1,5 +1,4 @@ -""" -OAuth2 Authentication implementation for HTTPX. +"""OAuth2 Authentication implementation for HTTPX. Implements authorization code flow with PKCE and automatic token refresh. """ diff --git a/src/mcp/client/auth/extensions/client_credentials.py b/src/mcp/client/auth/extensions/client_credentials.py index e2f3f08a4..cb6dafb40 100644 --- a/src/mcp/client/auth/extensions/client_credentials.py +++ b/src/mcp/client/auth/extensions/client_credentials.py @@ -1,5 +1,4 @@ -""" -OAuth client credential extensions for MCP. +"""OAuth client credential extensions for MCP. Provides OAuth providers for machine-to-machine authentication flows: - ClientCredentialsOAuthProvider: For client_credentials with client_id + client_secret @@ -9,6 +8,7 @@ """ import time +import warnings from collections.abc import Awaitable, Callable from typing import Any, Literal from uuid import uuid4 @@ -409,8 +409,6 @@ def __init__( timeout: float = 300.0, jwt_parameters: JWTParameters | None = None, ) -> None: - import warnings - warnings.warn( "RFC7523OAuthClientProvider is deprecated. Use ClientCredentialsOAuthProvider " "or PrivateKeyJWTOAuthProvider instead.", @@ -452,7 +450,7 @@ def _add_client_authentication_jwt(self, *, token_data: dict[str, Any]): # prag # When using private_key_jwt, in a client_credentials flow, we use RFC 7523 Section 2.2 token_data["client_assertion"] = assertion token_data["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" - # We need to set the audience to the resource server, the audience is difference from the one in claims + # We need to set the audience to the resource server, the audience is different from the one in claims # it represents the resource server that will validate the token token_data["audience"] = self.context.get_resource_url() diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index cd96a7566..7f5af5186 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -1,5 +1,4 @@ -""" -OAuth2 Authentication implementation for HTTPX. +"""OAuth2 Authentication implementation for HTTPX. Implements authorization code flow with PKCE and automatic token refresh. """ @@ -193,7 +192,7 @@ def prepare_token_auth( headers = {} # pragma: no cover if not self.client_info: - return data, headers # pragma: no cover + return data, headers auth_method = self.client_info.token_endpoint_auth_method @@ -215,8 +214,8 @@ def prepare_token_auth( class OAuthClientProvider(httpx.Auth): - """ - OAuth2 authentication for httpx. + """OAuth2 authentication for httpx. + Handles OAuth flow with automatic client registration and token storage. """ @@ -231,6 +230,7 @@ def __init__( callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None = None, timeout: float = 300.0, client_metadata_url: str | None = None, + validate_resource_url: Callable[[str, str | None], Awaitable[None]] | None = None, ): """Initialize OAuth2 authentication. @@ -242,9 +242,13 @@ def __init__( callback_handler: Handler for authorization callbacks. timeout: Timeout for the OAuth flow. client_metadata_url: URL-based client ID. When provided and the server - advertises client_id_metadata_document_supported=true, this URL will be + advertises client_id_metadata_document_supported=True, this URL will be used as the client_id instead of performing dynamic client registration. Must be a valid HTTPS URL with a non-root pathname. + validate_resource_url: Optional callback to override resource URL validation. + Called with (server_url, prm_resource) where prm_resource is the resource + from Protected Resource Metadata (or None if not present). If not provided, + default validation rejects mismatched resources per RFC 8707. Raises: ValueError: If client_metadata_url is provided but not a valid HTTPS URL @@ -265,11 +269,11 @@ def __init__( timeout=timeout, client_metadata_url=client_metadata_url, ) + self._validate_resource_url_callback = validate_resource_url self._initialized = False async def _handle_protected_resource_response(self, response: httpx.Response) -> bool: - """ - Handle protected resource metadata discovery response. + """Handle protected resource metadata discovery response. Per SEP-985, supports fallback when discovery fails at one URL. @@ -399,7 +403,7 @@ async def _exchange_token_authorization_code( async def _handle_token_response(self, response: httpx.Response) -> None: """Handle token exchange response.""" - if response.status_code != 200: + if response.status_code not in {200, 201}: body = await response.aread() # pragma: no cover body_text = body.decode("utf-8") # pragma: no cover raise OAuthTokenError(f"Token exchange failed ({response.status_code}): {body_text}") # pragma: no cover @@ -421,7 +425,7 @@ async def _refresh_token(self) -> httpx.Request: raise OAuthTokenError("No client info available") # pragma: no cover if self.context.oauth_metadata and self.context.oauth_metadata.token_endpoint: - token_url = str(self.context.oauth_metadata.token_endpoint) # pragma: no cover + token_url = str(self.context.oauth_metadata.token_endpoint) else: auth_base_url = self.context.get_authorization_base_url(self.context.server_url) token_url = urljoin(auth_base_url, "/token") @@ -479,6 +483,20 @@ async def _handle_oauth_metadata_response(self, response: httpx.Response) -> Non metadata = OAuthMetadata.model_validate_json(content) self.context.oauth_metadata = metadata + async def _validate_resource_match(self, prm: ProtectedResourceMetadata) -> None: + """Validate that PRM resource matches the server URL per RFC 8707.""" + prm_resource = str(prm.resource) if prm.resource else None + + if self._validate_resource_url_callback is not None: + await self._validate_resource_url_callback(self.context.server_url, prm_resource) + return + + if not prm_resource: + return # pragma: no cover + default_resource = resource_url_from_server_url(self.context.server_url) + if not check_resource_allowed(requested_resource=default_resource, configured_resource=prm_resource): + raise OAuthFlowError(f"Protected resource {prm_resource} does not match expected {default_resource}") + async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]: """HTTPX auth flow integration.""" async with self.context.lock: @@ -520,6 +538,8 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. prm = await handle_protected_resource_response(discovery_response) if prm: + # Validate PRM resource matches server URL (RFC 8707) + await self._validate_resource_match(prm) self.context.protected_resource_metadata = prm # todo: try all authorization_servers to find the OASM @@ -537,7 +557,7 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. ) # Step 2: Discover OAuth Authorization Server Metadata (OASM) (with fallback for legacy servers) - for url in asm_discovery_urls: # pragma: no cover + for url in asm_discovery_urls: # pragma: no branch oauth_metadata_request = create_oauth_metadata_request(url) oauth_metadata_response = yield oauth_metadata_request diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index b4426be7f..0ca36b98d 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -1,4 +1,3 @@ -import logging import re from urllib.parse import urljoin, urlparse @@ -16,12 +15,9 @@ ) from mcp.types import LATEST_PROTOCOL_VERSION -logger = logging.getLogger(__name__) - def extract_field_from_www_auth(response: Response, field_name: str) -> str | None: - """ - Extract field from WWW-Authenticate header. + """Extract field from WWW-Authenticate header. Returns: Field value if found in WWW-Authenticate header, None otherwise @@ -42,8 +38,7 @@ def extract_field_from_www_auth(response: Response, field_name: str) -> str | No def extract_scope_from_www_auth(response: Response) -> str | None: - """ - Extract scope parameter from WWW-Authenticate header as per RFC6750. + """Extract scope parameter from WWW-Authenticate header as per RFC 6750. Returns: Scope string if found in WWW-Authenticate header, None otherwise @@ -52,8 +47,7 @@ def extract_scope_from_www_auth(response: Response) -> str | None: def extract_resource_metadata_from_www_auth(response: Response) -> str | None: - """ - Extract protected resource metadata URL from WWW-Authenticate header as per RFC9728. + """Extract protected resource metadata URL from WWW-Authenticate header as per RFC 9728. Returns: Resource metadata URL if found in WWW-Authenticate header, None otherwise @@ -65,8 +59,7 @@ def extract_resource_metadata_from_www_auth(response: Response) -> str | None: def build_protected_resource_metadata_discovery_urls(www_auth_url: str | None, server_url: str) -> list[str]: - """ - Build ordered list of URLs to try for protected resource metadata discovery. + """Build ordered list of URLs to try for protected resource metadata discovery. Per SEP-985, the client MUST: 1. Try resource_metadata from WWW-Authenticate header (if present) @@ -74,8 +67,8 @@ def build_protected_resource_metadata_discovery_urls(www_auth_url: str | None, s 3. Fall back to root-based well-known URI: /.well-known/oauth-protected-resource Args: - www_auth_url: optional resource_metadata url extracted from the WWW-Authenticate header - server_url: server url + www_auth_url: Optional resource_metadata URL extracted from the WWW-Authenticate header + server_url: Server URL Returns: Ordered list of URLs to try for discovery @@ -127,11 +120,10 @@ def get_client_metadata_scopes( def build_oauth_authorization_server_metadata_discovery_urls(auth_server_url: str | None, server_url: str) -> list[str]: - """ - Generate ordered list of (url, type) tuples for discovery attempts. + """Generate an ordered list of URLs for authorization server metadata discovery. Args: - auth_server_url: URL for the OAuth Authorization Metadata URL if found, otherwise None + auth_server_url: OAuth Authorization Server Metadata URL if found, otherwise None server_url: URL for the MCP server, used as a fallback if auth_server_url is None """ @@ -173,13 +165,12 @@ def build_oauth_authorization_server_metadata_discovery_urls(auth_server_url: st async def handle_protected_resource_response( response: Response, ) -> ProtectedResourceMetadata | None: - """ - Handle protected resource metadata discovery response. + """Handle protected resource metadata discovery response. Per SEP-985, supports fallback when discovery fails at one URL. Returns: - True if metadata was successfully discovered, False if we should try next URL + ProtectedResourceMetadata if successfully discovered, None if we should try next URL """ if response.status_code == 200: try: @@ -215,7 +206,7 @@ def create_oauth_metadata_request(url: str) -> Request: def create_client_registration_request( auth_server_metadata: OAuthMetadata | None, client_metadata: OAuthClientMetadata, auth_base_url: str ) -> Request: - """Build registration request or skip if already registered.""" + """Build a client registration request.""" if auth_server_metadata and auth_server_metadata.registration_endpoint: registration_url = str(auth_server_metadata.registration_endpoint) @@ -270,7 +261,7 @@ def should_use_client_metadata_url( """Determine if URL-based client ID (CIMD) should be used instead of DCR. URL-based client IDs should be used when: - 1. The server advertises client_id_metadata_document_supported=true + 1. The server advertises client_id_metadata_document_supported=True 2. The client has a valid client_metadata_url configured Args: @@ -315,7 +306,7 @@ def create_client_info_from_metadata_url( async def handle_token_response_scopes( response: Response, ) -> OAuthToken: - """Parse and validate token response with optional scope validation. + """Parse and validate a token response. Parses token response JSON. Callers should check response.status_code before calling. diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py new file mode 100644 index 000000000..7dc67c584 --- /dev/null +++ b/src/mcp/client/client.py @@ -0,0 +1,301 @@ +"""Unified MCP Client that wraps ClientSession with transport management.""" + +from __future__ import annotations + +from contextlib import AsyncExitStack +from dataclasses import KW_ONLY, dataclass, field +from typing import Any + +from mcp.client._memory import InMemoryTransport +from mcp.client._transport import Transport +from mcp.client.session import ClientSession, ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT +from mcp.client.streamable_http import streamable_http_client +from mcp.server import Server +from mcp.server.mcpserver import MCPServer +from mcp.shared.session import ProgressFnT +from mcp.types import ( + CallToolResult, + CompleteResult, + EmptyResult, + GetPromptResult, + Implementation, + ListPromptsResult, + ListResourcesResult, + ListResourceTemplatesResult, + ListToolsResult, + LoggingLevel, + PaginatedRequestParams, + PromptReference, + ReadResourceResult, + RequestParamsMeta, + ResourceTemplateReference, + ServerCapabilities, +) + + +@dataclass +class Client: + """A high-level MCP client for connecting to MCP servers. + + Supports in-memory transport for testing (pass a Server or MCPServer instance), + Streamable HTTP transport (pass a URL string), or a custom Transport instance. + + Example: + ```python + from mcp.client import Client + from mcp.server.mcpserver import MCPServer + + server = MCPServer("test") + + @server.tool() + def add(a: int, b: int) -> int: + return a + b + + async def main(): + async with Client(server) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + + asyncio.run(main()) + ``` + """ + + server: Server[Any] | MCPServer | Transport | str + """The MCP server to connect to. + + If the server is a `Server` or `MCPServer` instance, it will be wrapped in an `InMemoryTransport`. + If the server is a URL string, it will be used as the URL for a `streamable_http_client` transport. + If the server is a `Transport` instance, it will be used directly. + """ + + _: KW_ONLY + + # TODO(Marcelo): When do `raise_exceptions=True` actually raises? + raise_exceptions: bool = False + """Whether to raise exceptions from the server.""" + + read_timeout_seconds: float | None = None + """Timeout for read operations.""" + + sampling_callback: SamplingFnT | None = None + """Callback for handling sampling requests.""" + + list_roots_callback: ListRootsFnT | None = None + """Callback for handling list roots requests.""" + + logging_callback: LoggingFnT | None = None + """Callback for handling logging notifications.""" + + # TODO(Marcelo): Why do we have both "callback" and "handler"? + message_handler: MessageHandlerFnT | None = None + """Callback for handling raw messages.""" + + client_info: Implementation | None = None + """Client implementation info to send to server.""" + + elicitation_callback: ElicitationFnT | None = None + """Callback for handling elicitation requests.""" + + _session: ClientSession | None = field(init=False, default=None) + _exit_stack: AsyncExitStack | None = field(init=False, default=None) + _transport: Transport = field(init=False) + + def __post_init__(self) -> None: + if isinstance(self.server, Server | MCPServer): + self._transport = InMemoryTransport(self.server, raise_exceptions=self.raise_exceptions) + elif isinstance(self.server, str): + self._transport = streamable_http_client(self.server) + else: + self._transport = self.server + + async def __aenter__(self) -> Client: + """Enter the async context manager.""" + if self._session is not None: + raise RuntimeError("Client is already entered; cannot reenter") + + async with AsyncExitStack() as exit_stack: + read_stream, write_stream = await exit_stack.enter_async_context(self._transport) + + self._session = await exit_stack.enter_async_context( + ClientSession( + read_stream=read_stream, + write_stream=write_stream, + read_timeout_seconds=self.read_timeout_seconds, + sampling_callback=self.sampling_callback, + list_roots_callback=self.list_roots_callback, + logging_callback=self.logging_callback, + message_handler=self.message_handler, + client_info=self.client_info, + elicitation_callback=self.elicitation_callback, + ) + ) + + await self._session.initialize() + + # Transfer ownership to self for __aexit__ to handle + self._exit_stack = exit_stack.pop_all() + return self + + async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None: + """Exit the async context manager.""" + if self._exit_stack: # pragma: no branch + await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb) + self._session = None + + @property + def session(self) -> ClientSession: + """Get the underlying ClientSession. + + This provides access to the full ClientSession API for advanced use cases. + + Raises: + RuntimeError: If accessed before entering the context manager. + """ + if self._session is None: + raise RuntimeError("Client must be used within an async context manager") + return self._session + + @property + def server_capabilities(self) -> ServerCapabilities | None: + """The server capabilities received during initialization, or None if not yet initialized.""" + return self.session.get_server_capabilities() + + async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> EmptyResult: + """Send a ping request to the server.""" + return await self.session.send_ping(meta=meta) + + async def send_progress_notification( + self, + progress_token: str | int, + progress: float, + total: float | None = None, + message: str | None = None, + ) -> None: + """Send a progress notification to the server.""" + await self.session.send_progress_notification( + progress_token=progress_token, + progress=progress, + total=total, + message=message, + ) + + async def set_logging_level(self, level: LoggingLevel, *, meta: RequestParamsMeta | None = None) -> EmptyResult: + """Set the logging level on the server.""" + return await self.session.set_logging_level(level=level, meta=meta) + + async def list_resources( + self, + *, + cursor: str | None = None, + meta: RequestParamsMeta | None = None, + ) -> ListResourcesResult: + """List available resources from the server.""" + return await self.session.list_resources(params=PaginatedRequestParams(cursor=cursor, _meta=meta)) + + async def list_resource_templates( + self, + *, + cursor: str | None = None, + meta: RequestParamsMeta | None = None, + ) -> ListResourceTemplatesResult: + """List available resource templates from the server.""" + return await self.session.list_resource_templates(params=PaginatedRequestParams(cursor=cursor, _meta=meta)) + + async def read_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> ReadResourceResult: + """Read a resource from the server. + + Args: + uri: The URI of the resource to read. + meta: Additional metadata for the request. + + Returns: + The resource content. + """ + return await self.session.read_resource(uri, meta=meta) + + async def subscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> EmptyResult: + """Subscribe to resource updates.""" + return await self.session.subscribe_resource(uri, meta=meta) + + async def unsubscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> EmptyResult: + """Unsubscribe from resource updates.""" + return await self.session.unsubscribe_resource(uri, meta=meta) + + async def call_tool( + self, + name: str, + arguments: dict[str, Any] | None = None, + read_timeout_seconds: float | None = None, + progress_callback: ProgressFnT | None = None, + *, + meta: RequestParamsMeta | None = None, + ) -> CallToolResult: + """Call a tool on the server. + + Args: + name: The name of the tool to call + arguments: Arguments to pass to the tool + read_timeout_seconds: Timeout for the tool call + progress_callback: Callback for progress updates + meta: Additional metadata for the request + + Returns: + The tool result. + """ + return await self.session.call_tool( + name=name, + arguments=arguments, + read_timeout_seconds=read_timeout_seconds, + progress_callback=progress_callback, + meta=meta, + ) + + async def list_prompts( + self, + *, + cursor: str | None = None, + meta: RequestParamsMeta | None = None, + ) -> ListPromptsResult: + """List available prompts from the server.""" + return await self.session.list_prompts(params=PaginatedRequestParams(cursor=cursor, _meta=meta)) + + async def get_prompt( + self, name: str, arguments: dict[str, str] | None = None, *, meta: RequestParamsMeta | None = None + ) -> GetPromptResult: + """Get a prompt from the server. + + Args: + name: The name of the prompt + arguments: Arguments to pass to the prompt + meta: Additional metadata for the request + + Returns: + The prompt content. + """ + return await self.session.get_prompt(name=name, arguments=arguments, meta=meta) + + async def complete( + self, + ref: ResourceTemplateReference | PromptReference, + argument: dict[str, str], + context_arguments: dict[str, str] | None = None, + ) -> CompleteResult: + """Get completions for a prompt or resource template argument. + + Args: + ref: Reference to the prompt or resource template + argument: The argument to complete + context_arguments: Additional context arguments + + Returns: + Completion suggestions. + """ + return await self.session.complete(ref=ref, argument=argument, context_arguments=context_arguments) + + async def list_tools(self, *, cursor: str | None = None, meta: RequestParamsMeta | None = None) -> ListToolsResult: + """List available tools from the server.""" + return await self.session.list_tools(params=PaginatedRequestParams(cursor=cursor, _meta=meta)) + + async def send_roots_list_changed(self) -> None: + """Send a notification that the roots list has changed.""" + # TODO(Marcelo): Currently, there is no way for the server to handle this. We should add support. + await self.session.send_roots_list_changed() # pragma: no cover diff --git a/src/mcp/client/context.py b/src/mcp/client/context.py new file mode 100644 index 000000000..2f4404e00 --- /dev/null +++ b/src/mcp/client/context.py @@ -0,0 +1,16 @@ +"""Request context for MCP client handlers.""" + +from mcp.client.session import ClientSession +from mcp.shared._context import RequestContext + +ClientRequestContext = RequestContext[ClientSession] +"""Context for handling incoming requests in a client session. + +This context is passed to client-side callbacks (sampling, elicitation, list_roots) when the server sends requests +to the client. + +Attributes: + request_id: The unique identifier for this request. + meta: Optional metadata associated with the request. + session: The client session handling this request. +""" diff --git a/src/mcp/client/experimental/__init__.py b/src/mcp/client/experimental/__init__.py index b6579b191..8d74cb304 100644 --- a/src/mcp/client/experimental/__init__.py +++ b/src/mcp/client/experimental/__init__.py @@ -1,5 +1,4 @@ -""" -Experimental client features. +"""Experimental client features. WARNING: These APIs are experimental and may change without notice. """ diff --git a/src/mcp/client/experimental/task_handlers.py b/src/mcp/client/experimental/task_handlers.py index a47508674..0ab513236 100644 --- a/src/mcp/client/experimental/task_handlers.py +++ b/src/mcp/client/experimental/task_handlers.py @@ -1,5 +1,4 @@ -""" -Experimental task handler protocols for server -> client requests. +"""Experimental task handler protocols for server -> client requests. This module provides Protocol types and default handlers for when servers send task-related requests to clients (the reverse of normal client -> server flow). @@ -12,13 +11,15 @@ - Server polls client's task status via tasks/get, tasks/result, etc. """ +from __future__ import annotations + from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Protocol +from typing import TYPE_CHECKING, Protocol from pydantic import TypeAdapter -import mcp.types as types -from mcp.shared.context import RequestContext +from mcp import types +from mcp.shared._context import RequestContext from mcp.shared.session import RequestResponder if TYPE_CHECKING: @@ -33,7 +34,7 @@ class GetTaskHandlerFnT(Protocol): async def __call__( self, - context: RequestContext["ClientSession", Any], + context: RequestContext[ClientSession], params: types.GetTaskRequestParams, ) -> types.GetTaskResult | types.ErrorData: ... # pragma: no branch @@ -46,7 +47,7 @@ class GetTaskResultHandlerFnT(Protocol): async def __call__( self, - context: RequestContext["ClientSession", Any], + context: RequestContext[ClientSession], params: types.GetTaskPayloadRequestParams, ) -> types.GetTaskPayloadResult | types.ErrorData: ... # pragma: no branch @@ -59,7 +60,7 @@ class ListTasksHandlerFnT(Protocol): async def __call__( self, - context: RequestContext["ClientSession", Any], + context: RequestContext[ClientSession], params: types.PaginatedRequestParams | None, ) -> types.ListTasksResult | types.ErrorData: ... # pragma: no branch @@ -72,7 +73,7 @@ class CancelTaskHandlerFnT(Protocol): async def __call__( self, - context: RequestContext["ClientSession", Any], + context: RequestContext[ClientSession], params: types.CancelTaskRequestParams, ) -> types.CancelTaskResult | types.ErrorData: ... # pragma: no branch @@ -89,7 +90,7 @@ class TaskAugmentedSamplingFnT(Protocol): async def __call__( self, - context: RequestContext["ClientSession", Any], + context: RequestContext[ClientSession], params: types.CreateMessageRequestParams, task_metadata: types.TaskMetadata, ) -> types.CreateTaskResult | types.ErrorData: ... # pragma: no branch @@ -107,14 +108,14 @@ class TaskAugmentedElicitationFnT(Protocol): async def __call__( self, - context: RequestContext["ClientSession", Any], + context: RequestContext[ClientSession], params: types.ElicitRequestParams, task_metadata: types.TaskMetadata, ) -> types.CreateTaskResult | types.ErrorData: ... # pragma: no branch async def default_get_task_handler( - context: RequestContext["ClientSession", Any], + context: RequestContext[ClientSession], params: types.GetTaskRequestParams, ) -> types.GetTaskResult | types.ErrorData: return types.ErrorData( @@ -124,7 +125,7 @@ async def default_get_task_handler( async def default_get_task_result_handler( - context: RequestContext["ClientSession", Any], + context: RequestContext[ClientSession], params: types.GetTaskPayloadRequestParams, ) -> types.GetTaskPayloadResult | types.ErrorData: return types.ErrorData( @@ -134,7 +135,7 @@ async def default_get_task_result_handler( async def default_list_tasks_handler( - context: RequestContext["ClientSession", Any], + context: RequestContext[ClientSession], params: types.PaginatedRequestParams | None, ) -> types.ListTasksResult | types.ErrorData: return types.ErrorData( @@ -144,7 +145,7 @@ async def default_list_tasks_handler( async def default_cancel_task_handler( - context: RequestContext["ClientSession", Any], + context: RequestContext[ClientSession], params: types.CancelTaskRequestParams, ) -> types.CancelTaskResult | types.ErrorData: return types.ErrorData( @@ -154,7 +155,7 @@ async def default_cancel_task_handler( async def default_task_augmented_sampling( - context: RequestContext["ClientSession", Any], + context: RequestContext[ClientSession], params: types.CreateMessageRequestParams, task_metadata: types.TaskMetadata, ) -> types.CreateTaskResult | types.ErrorData: @@ -165,7 +166,7 @@ async def default_task_augmented_sampling( async def default_task_augmented_elicitation( - context: RequestContext["ClientSession", Any], + context: RequestContext[ClientSession], params: types.ElicitRequestParams, task_metadata: types.TaskMetadata, ) -> types.CreateTaskResult | types.ErrorData: @@ -186,11 +187,13 @@ class ExperimentalTaskHandlers: WARNING: These APIs are experimental and may change without notice. Example: + ```python handlers = ExperimentalTaskHandlers( get_task=my_get_task_handler, list_tasks=my_list_tasks_handler, ) session = ClientSession(..., experimental_task_handlers=handlers) + ``` """ # Pure task request handlers @@ -225,7 +228,7 @@ def build_capability(self) -> types.ClientTasksCapability | None: requests_capability: types.ClientTasksRequestsCapability | None = None if has_sampling or has_elicitation: requests_capability = types.ClientTasksRequestsCapability( - sampling=types.TasksSamplingCapability(createMessage=types.TasksCreateMessageCapability()) + sampling=types.TasksSamplingCapability(create_message=types.TasksCreateMessageCapability()) if has_sampling else None, elicitation=types.TasksElicitationCapability(create=types.TasksCreateElicitationCapability()) @@ -243,13 +246,13 @@ def build_capability(self) -> types.ClientTasksCapability | None: def handles_request(request: types.ServerRequest) -> bool: """Check if this handler handles the given request type.""" return isinstance( - request.root, + request, types.GetTaskRequest | types.GetTaskPayloadRequest | types.ListTasksRequest | types.CancelTaskRequest, ) async def handle_request( self, - ctx: RequestContext["ClientSession", Any], + ctx: RequestContext[ClientSession], responder: RequestResponder[types.ServerRequest, types.ClientResult], ) -> None: """Handle a task-related request from the server. @@ -260,7 +263,7 @@ async def handle_request( types.ClientResult | types.ErrorData ) - match responder.request.root: + match responder.request: case types.GetTaskRequest(params=params): response = await self.get_task(ctx, params) client_response = client_response_type.validate_python(response) @@ -282,7 +285,7 @@ async def handle_request( await responder.respond(client_response) case _: # pragma: no cover - raise ValueError(f"Unhandled request type: {type(responder.request.root)}") + raise ValueError(f"Unhandled request type: {type(responder.request)}") # Backwards compatibility aliases diff --git a/src/mcp/client/experimental/tasks.py b/src/mcp/client/experimental/tasks.py index ce9c38746..a566df766 100644 --- a/src/mcp/client/experimental/tasks.py +++ b/src/mcp/client/experimental/tasks.py @@ -1,14 +1,14 @@ -""" -Experimental client-side task support. +"""Experimental client-side task support. This module provides client methods for interacting with MCP tasks. WARNING: These APIs are experimental and may change without notice. Example: + ```python # Call a tool as a task result = await session.experimental.call_tool_as_task("tool_name", {"arg": "value"}) - task_id = result.task.taskId + task_id = result.task.task_id # Get task status status = await session.experimental.get_task(task_id) @@ -22,13 +22,15 @@ # Cancel a task await session.experimental.cancel_task(task_id) + ``` """ from collections.abc import AsyncIterator from typing import TYPE_CHECKING, Any, TypeVar -import mcp.types as types +from mcp import types from mcp.shared.experimental.tasks.polling import poll_until_terminal +from mcp.types._types import RequestParamsMeta if TYPE_CHECKING: from mcp.client.session import ClientSession @@ -37,8 +39,7 @@ class ExperimentalClientFeatures: - """ - Experimental client features for tasks and other experimental APIs. + """Experimental client features for tasks and other experimental APIs. WARNING: These APIs are experimental and may change without notice. @@ -55,7 +56,7 @@ async def call_tool_as_task( arguments: dict[str, Any] | None = None, *, ttl: int = 60000, - meta: dict[str, Any] | None = None, + meta: RequestParamsMeta | None = None, ) -> types.CreateTaskResult: """Call a tool as a task, returning a CreateTaskResult for polling. @@ -73,43 +74,38 @@ async def call_tool_as_task( CreateTaskResult containing the task reference Example: + ```python # Create task result = await session.experimental.call_tool_as_task( "long_running_tool", {"input": "data"} ) - task_id = result.task.taskId + task_id = result.task.task_id # Poll for completion while True: status = await session.experimental.get_task(task_id) if status.status == "completed": break - await asyncio.sleep(0.5) + await anyio.sleep(0.5) # Get result final = await session.experimental.get_task_result(task_id, CallToolResult) + ``` """ - _meta: types.RequestParams.Meta | None = None - if meta is not None: - _meta = types.RequestParams.Meta(**meta) - return await self._session.send_request( - types.ClientRequest( - types.CallToolRequest( - params=types.CallToolRequestParams( - name=name, - arguments=arguments, - task=types.TaskMetadata(ttl=ttl), - _meta=_meta, - ), - ) + types.CallToolRequest( + params=types.CallToolRequestParams( + name=name, + arguments=arguments, + task=types.TaskMetadata(ttl=ttl), + _meta=meta, + ), ), types.CreateTaskResult, ) async def get_task(self, task_id: str) -> types.GetTaskResult: - """ - Get the current status of a task. + """Get the current status of a task. Args: task_id: The task identifier @@ -118,11 +114,7 @@ async def get_task(self, task_id: str) -> types.GetTaskResult: GetTaskResult containing the task status and metadata """ return await self._session.send_request( - types.ClientRequest( - types.GetTaskRequest( - params=types.GetTaskRequestParams(taskId=task_id), - ) - ), + types.GetTaskRequest(params=types.GetTaskRequestParams(task_id=task_id)), types.GetTaskResult, ) @@ -131,8 +123,7 @@ async def get_task_result( task_id: str, result_type: type[ResultT], ) -> ResultT: - """ - Get the result of a completed task. + """Get the result of a completed task. The result type depends on the original request type: - tools/call tasks return CallToolResult @@ -146,10 +137,8 @@ async def get_task_result( The task result, validated against result_type """ return await self._session.send_request( - types.ClientRequest( - types.GetTaskPayloadRequest( - params=types.GetTaskPayloadRequestParams(taskId=task_id), - ) + types.GetTaskPayloadRequest( + params=types.GetTaskPayloadRequestParams(task_id=task_id), ), result_type, ) @@ -158,8 +147,7 @@ async def list_tasks( self, cursor: str | None = None, ) -> types.ListTasksResult: - """ - List all tasks. + """List all tasks. Args: cursor: Optional pagination cursor @@ -169,15 +157,12 @@ async def list_tasks( """ params = types.PaginatedRequestParams(cursor=cursor) if cursor else None return await self._session.send_request( - types.ClientRequest( - types.ListTasksRequest(params=params), - ), + types.ListTasksRequest(params=params), types.ListTasksResult, ) async def cancel_task(self, task_id: str) -> types.CancelTaskResult: - """ - Cancel a running task. + """Cancel a running task. Args: task_id: The task identifier @@ -186,20 +171,17 @@ async def cancel_task(self, task_id: str) -> types.CancelTaskResult: CancelTaskResult with the updated task state """ return await self._session.send_request( - types.ClientRequest( - types.CancelTaskRequest( - params=types.CancelTaskRequestParams(taskId=task_id), - ) + types.CancelTaskRequest( + params=types.CancelTaskRequestParams(task_id=task_id), ), types.CancelTaskResult, ) async def poll_task(self, task_id: str) -> AsyncIterator[types.GetTaskResult]: - """ - Poll a task until it reaches a terminal status. + """Poll a task until it reaches a terminal status. Yields GetTaskResult for each poll, allowing the caller to react to - status changes (e.g., handle input_required). Exits when task reaches + status changes (e.g., handle input_required). Exits when the task reaches a terminal status (completed, failed, cancelled). Respects the pollInterval hint from the server. @@ -211,6 +193,7 @@ async def poll_task(self, task_id: str) -> AsyncIterator[types.GetTaskResult]: GetTaskResult for each poll Example: + ```python async for status in session.experimental.poll_task(task_id): print(f"Status: {status.status}") if status.status == "input_required": @@ -219,6 +202,7 @@ async def poll_task(self, task_id: str) -> AsyncIterator[types.GetTaskResult]: # Task is now terminal, get the result result = await session.experimental.get_task_result(task_id, CallToolResult) + ``` """ async for status in poll_until_terminal(self.get_task, task_id): yield status diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 53fc53a1f..a0ca751bd 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -1,19 +1,20 @@ +from __future__ import annotations + import logging -from datetime import timedelta -from typing import Any, Protocol, overload +from typing import Any, Protocol import anyio.lowlevel from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from pydantic import AnyUrl, TypeAdapter -from typing_extensions import deprecated +from pydantic import TypeAdapter -import mcp.types as types +from mcp import types from mcp.client.experimental import ExperimentalClientFeatures from mcp.client.experimental.task_handlers import ExperimentalTaskHandlers -from mcp.shared.context import RequestContext +from mcp.shared._context import RequestContext from mcp.shared.message import SessionMessage from mcp.shared.session import BaseSession, ProgressFnT, RequestResponder from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS +from mcp.types._types import RequestParamsMeta DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0") @@ -23,30 +24,27 @@ class SamplingFnT(Protocol): async def __call__( self, - context: RequestContext["ClientSession", Any], + context: RequestContext[ClientSession], params: types.CreateMessageRequestParams, - ) -> types.CreateMessageResult | types.ErrorData: ... # pragma: no branch + ) -> types.CreateMessageResult | types.CreateMessageResultWithTools | types.ErrorData: ... # pragma: no branch class ElicitationFnT(Protocol): async def __call__( self, - context: RequestContext["ClientSession", Any], + context: RequestContext[ClientSession], params: types.ElicitRequestParams, ) -> types.ElicitResult | types.ErrorData: ... # pragma: no branch class ListRootsFnT(Protocol): async def __call__( - self, context: RequestContext["ClientSession", Any] + self, context: RequestContext[ClientSession] ) -> types.ListRootsResult | types.ErrorData: ... # pragma: no branch class LoggingFnT(Protocol): - async def __call__( - self, - params: types.LoggingMessageNotificationParams, - ) -> None: ... # pragma: no branch + async def __call__(self, params: types.LoggingMessageNotificationParams) -> None: ... # pragma: no branch class MessageHandlerFnT(Protocol): @@ -63,9 +61,9 @@ async def _default_message_handler( async def _default_sampling_callback( - context: RequestContext["ClientSession", Any], + context: RequestContext[ClientSession], params: types.CreateMessageRequestParams, -) -> types.CreateMessageResult | types.ErrorData: +) -> types.CreateMessageResult | types.CreateMessageResultWithTools | types.ErrorData: return types.ErrorData( code=types.INVALID_REQUEST, message="Sampling not supported", @@ -73,7 +71,7 @@ async def _default_sampling_callback( async def _default_elicitation_callback( - context: RequestContext["ClientSession", Any], + context: RequestContext[ClientSession], params: types.ElicitRequestParams, ) -> types.ElicitResult | types.ErrorData: return types.ErrorData( # pragma: no cover @@ -83,7 +81,7 @@ async def _default_elicitation_callback( async def _default_list_roots_callback( - context: RequestContext["ClientSession", Any], + context: RequestContext[ClientSession], ) -> types.ListRootsResult | types.ErrorData: return types.ErrorData( code=types.INVALID_REQUEST, @@ -113,7 +111,7 @@ def __init__( self, read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], write_stream: MemoryObjectSendStream[SessionMessage], - read_timeout_seconds: timedelta | None = None, + read_timeout_seconds: float | None = None, sampling_callback: SamplingFnT | None = None, elicitation_callback: ElicitationFnT | None = None, list_roots_callback: ListRootsFnT | None = None, @@ -121,17 +119,13 @@ def __init__( message_handler: MessageHandlerFnT | None = None, client_info: types.Implementation | None = None, *, + sampling_capabilities: types.SamplingCapability | None = None, experimental_task_handlers: ExperimentalTaskHandlers | None = None, ) -> None: - super().__init__( - read_stream, - write_stream, - types.ServerRequest, - types.ServerNotification, - read_timeout_seconds=read_timeout_seconds, - ) + super().__init__(read_stream, write_stream, read_timeout_seconds=read_timeout_seconds) self._client_info = client_info or DEFAULT_CLIENT_INFO self._sampling_callback = sampling_callback or _default_sampling_callback + self._sampling_capabilities = sampling_capabilities self._elicitation_callback = elicitation_callback or _default_elicitation_callback self._list_roots_callback = list_roots_callback or _default_list_roots_callback self._logging_callback = logging_callback or _default_logging_callback @@ -143,13 +137,22 @@ def __init__( # Experimental: Task handlers (use defaults if not provided) self._task_handlers = experimental_task_handlers or ExperimentalTaskHandlers() + @property + def _receive_request_adapter(self) -> TypeAdapter[types.ServerRequest]: + return types.server_request_adapter + + @property + def _receive_notification_adapter(self) -> TypeAdapter[types.ServerNotification]: + return types.server_notification_adapter + async def initialize(self) -> types.InitializeResult: - sampling = types.SamplingCapability() if self._sampling_callback is not _default_sampling_callback else None + sampling = ( + (self._sampling_capabilities or types.SamplingCapability()) + if self._sampling_callback is not _default_sampling_callback + else None + ) elicitation = ( - types.ElicitationCapability( - form=types.FormElicitationCapability(), - url=types.UrlElicitationCapability(), - ) + types.ElicitationCapability(form=types.FormElicitationCapability(), url=types.UrlElicitationCapability()) if self._elicitation_callback is not _default_elicitation_callback else None ) @@ -157,36 +160,34 @@ async def initialize(self) -> types.InitializeResult: # TODO: Should this be based on whether we # _will_ send notifications, or only whether # they're supported? - types.RootsCapability(listChanged=True) + types.RootsCapability(list_changed=True) if self._list_roots_callback is not _default_list_roots_callback else None ) result = await self.send_request( - types.ClientRequest( - types.InitializeRequest( - params=types.InitializeRequestParams( - protocolVersion=types.LATEST_PROTOCOL_VERSION, - capabilities=types.ClientCapabilities( - sampling=sampling, - elicitation=elicitation, - experimental=None, - roots=roots, - tasks=self._task_handlers.build_capability(), - ), - clientInfo=self._client_info, + types.InitializeRequest( + params=types.InitializeRequestParams( + protocol_version=types.LATEST_PROTOCOL_VERSION, + capabilities=types.ClientCapabilities( + sampling=sampling, + elicitation=elicitation, + experimental=None, + roots=roots, + tasks=self._task_handlers.build_capability(), ), - ) + client_info=self._client_info, + ), ), types.InitializeResult, ) - if result.protocolVersion not in SUPPORTED_PROTOCOL_VERSIONS: - raise RuntimeError(f"Unsupported protocol version from the server: {result.protocolVersion}") + if result.protocol_version not in SUPPORTED_PROTOCOL_VERSIONS: + raise RuntimeError(f"Unsupported protocol version from the server: {result.protocol_version}") self._server_capabilities = result.capabilities - await self.send_notification(types.ClientNotification(types.InitializedNotification())) + await self.send_notification(types.InitializedNotification()) return result @@ -201,22 +202,22 @@ def get_server_capabilities(self) -> types.ServerCapabilities | None: def experimental(self) -> ExperimentalClientFeatures: """Experimental APIs for tasks and other features. - WARNING: These APIs are experimental and may change without notice. + !!! warning + These APIs are experimental and may change without notice. Example: + ```python status = await session.experimental.get_task(task_id) result = await session.experimental.get_task_result(task_id, CallToolResult) + ``` """ if self._experimental_features is None: self._experimental_features = ExperimentalClientFeatures(self) return self._experimental_features - async def send_ping(self) -> types.EmptyResult: + async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult: """Send a ping request.""" - return await self.send_request( - types.ClientRequest(types.PingRequest()), - types.EmptyResult, - ) + return await self.send_request(types.PingRequest(params=types.RequestParams(_meta=meta)), types.EmptyResult) async def send_progress_notification( self, @@ -224,138 +225,73 @@ async def send_progress_notification( progress: float, total: float | None = None, message: str | None = None, + *, + meta: RequestParamsMeta | None = None, ) -> None: """Send a progress notification.""" await self.send_notification( - types.ClientNotification( - types.ProgressNotification( - params=types.ProgressNotificationParams( - progressToken=progress_token, - progress=progress, - total=total, - message=message, - ), + types.ProgressNotification( + params=types.ProgressNotificationParams( + progress_token=progress_token, + progress=progress, + total=total, + message=message, + _meta=meta, ), ) ) - async def set_logging_level(self, level: types.LoggingLevel) -> types.EmptyResult: + async def set_logging_level( + self, + level: types.LoggingLevel, + *, + meta: RequestParamsMeta | None = None, + ) -> types.EmptyResult: """Send a logging/setLevel request.""" - return await self.send_request( # pragma: no cover - types.ClientRequest( - types.SetLevelRequest( - params=types.SetLevelRequestParams(level=level), - ) - ), + return await self.send_request( + types.SetLevelRequest(params=types.SetLevelRequestParams(level=level, _meta=meta)), types.EmptyResult, ) - @overload - @deprecated("Use list_resources(params=PaginatedRequestParams(...)) instead") - async def list_resources(self, cursor: str | None) -> types.ListResourcesResult: ... - - @overload - async def list_resources(self, *, params: types.PaginatedRequestParams | None) -> types.ListResourcesResult: ... - - @overload - async def list_resources(self) -> types.ListResourcesResult: ... - - async def list_resources( - self, - cursor: str | None = None, - *, - params: types.PaginatedRequestParams | None = None, - ) -> types.ListResourcesResult: + async def list_resources(self, *, params: types.PaginatedRequestParams | None = None) -> types.ListResourcesResult: """Send a resources/list request. Args: - cursor: Simple cursor string for pagination (deprecated, use params instead) params: Full pagination parameters including cursor and any future fields """ - if params is not None and cursor is not None: - raise ValueError("Cannot specify both cursor and params") - - if params is not None: - request_params = params - elif cursor is not None: - request_params = types.PaginatedRequestParams(cursor=cursor) - else: - request_params = None - - return await self.send_request( - types.ClientRequest(types.ListResourcesRequest(params=request_params)), - types.ListResourcesResult, - ) - - @overload - @deprecated("Use list_resource_templates(params=PaginatedRequestParams(...)) instead") - async def list_resource_templates(self, cursor: str | None) -> types.ListResourceTemplatesResult: ... - - @overload - async def list_resource_templates( - self, *, params: types.PaginatedRequestParams | None - ) -> types.ListResourceTemplatesResult: ... - - @overload - async def list_resource_templates(self) -> types.ListResourceTemplatesResult: ... + return await self.send_request(types.ListResourcesRequest(params=params), types.ListResourcesResult) async def list_resource_templates( - self, - cursor: str | None = None, - *, - params: types.PaginatedRequestParams | None = None, + self, *, params: types.PaginatedRequestParams | None = None ) -> types.ListResourceTemplatesResult: """Send a resources/templates/list request. Args: - cursor: Simple cursor string for pagination (deprecated, use params instead) params: Full pagination parameters including cursor and any future fields """ - if params is not None and cursor is not None: - raise ValueError("Cannot specify both cursor and params") - - if params is not None: - request_params = params - elif cursor is not None: - request_params = types.PaginatedRequestParams(cursor=cursor) - else: - request_params = None - return await self.send_request( - types.ClientRequest(types.ListResourceTemplatesRequest(params=request_params)), + types.ListResourceTemplatesRequest(params=params), types.ListResourceTemplatesResult, ) - async def read_resource(self, uri: AnyUrl) -> types.ReadResourceResult: + async def read_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> types.ReadResourceResult: """Send a resources/read request.""" return await self.send_request( - types.ClientRequest( - types.ReadResourceRequest( - params=types.ReadResourceRequestParams(uri=uri), - ) - ), + types.ReadResourceRequest(params=types.ReadResourceRequestParams(uri=uri, _meta=meta)), types.ReadResourceResult, ) - async def subscribe_resource(self, uri: AnyUrl) -> types.EmptyResult: + async def subscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult: """Send a resources/subscribe request.""" - return await self.send_request( # pragma: no cover - types.ClientRequest( - types.SubscribeRequest( - params=types.SubscribeRequestParams(uri=uri), - ) - ), + return await self.send_request( + types.SubscribeRequest(params=types.SubscribeRequestParams(uri=uri, _meta=meta)), types.EmptyResult, ) - async def unsubscribe_resource(self, uri: AnyUrl) -> types.EmptyResult: + async def unsubscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult: """Send a resources/unsubscribe request.""" - return await self.send_request( # pragma: no cover - types.ClientRequest( - types.UnsubscribeRequest( - params=types.UnsubscribeRequestParams(uri=uri), - ) - ), + return await self.send_request( + types.UnsubscribeRequest(params=types.UnsubscribeRequestParams(uri=uri, _meta=meta)), types.EmptyResult, ) @@ -363,29 +299,23 @@ async def call_tool( self, name: str, arguments: dict[str, Any] | None = None, - read_timeout_seconds: timedelta | None = None, + read_timeout_seconds: float | None = None, progress_callback: ProgressFnT | None = None, *, - meta: dict[str, Any] | None = None, + meta: RequestParamsMeta | None = None, ) -> types.CallToolResult: """Send a tools/call request with optional progress callback support.""" - _meta: types.RequestParams.Meta | None = None - if meta is not None: - _meta = types.RequestParams.Meta(**meta) - result = await self.send_request( - types.ClientRequest( - types.CallToolRequest( - params=types.CallToolRequestParams(name=name, arguments=arguments, _meta=_meta), - ) + types.CallToolRequest( + params=types.CallToolRequestParams(name=name, arguments=arguments, _meta=meta), ), types.CallToolResult, request_read_timeout_seconds=read_timeout_seconds, progress_callback=progress_callback, ) - if not result.isError: + if not result.is_error: await self._validate_tool_result(name, result) return result @@ -405,62 +335,35 @@ async def _validate_tool_result(self, name: str, result: types.CallToolResult) - if output_schema is not None: from jsonschema import SchemaError, ValidationError, validate - if result.structuredContent is None: + if result.structured_content is None: raise RuntimeError( f"Tool {name} has an output schema but did not return structured content" ) # pragma: no cover try: - validate(result.structuredContent, output_schema) + validate(result.structured_content, output_schema) except ValidationError as e: - raise RuntimeError(f"Invalid structured content returned by tool {name}: {e}") # pragma: no cover + raise RuntimeError(f"Invalid structured content returned by tool {name}: {e}") except SchemaError as e: # pragma: no cover raise RuntimeError(f"Invalid schema for tool {name}: {e}") # pragma: no cover - @overload - @deprecated("Use list_prompts(params=PaginatedRequestParams(...)) instead") - async def list_prompts(self, cursor: str | None) -> types.ListPromptsResult: ... - - @overload - async def list_prompts(self, *, params: types.PaginatedRequestParams | None) -> types.ListPromptsResult: ... - - @overload - async def list_prompts(self) -> types.ListPromptsResult: ... - - async def list_prompts( - self, - cursor: str | None = None, - *, - params: types.PaginatedRequestParams | None = None, - ) -> types.ListPromptsResult: + async def list_prompts(self, *, params: types.PaginatedRequestParams | None = None) -> types.ListPromptsResult: """Send a prompts/list request. Args: - cursor: Simple cursor string for pagination (deprecated, use params instead) params: Full pagination parameters including cursor and any future fields """ - if params is not None and cursor is not None: - raise ValueError("Cannot specify both cursor and params") - - if params is not None: - request_params = params - elif cursor is not None: - request_params = types.PaginatedRequestParams(cursor=cursor) - else: - request_params = None + return await self.send_request(types.ListPromptsRequest(params=params), types.ListPromptsResult) - return await self.send_request( - types.ClientRequest(types.ListPromptsRequest(params=request_params)), - types.ListPromptsResult, - ) - - async def get_prompt(self, name: str, arguments: dict[str, str] | None = None) -> types.GetPromptResult: + async def get_prompt( + self, + name: str, + arguments: dict[str, str] | None = None, + *, + meta: RequestParamsMeta | None = None, + ) -> types.GetPromptResult: """Send a prompts/get request.""" return await self.send_request( - types.ClientRequest( - types.GetPromptRequest( - params=types.GetPromptRequestParams(name=name, arguments=arguments), - ) - ), + types.GetPromptRequest(params=types.GetPromptRequestParams(name=name, arguments=arguments, _meta=meta)), types.GetPromptResult, ) @@ -476,73 +379,40 @@ async def complete( context = types.CompletionContext(arguments=context_arguments) return await self.send_request( - types.ClientRequest( - types.CompleteRequest( - params=types.CompleteRequestParams( - ref=ref, - argument=types.CompletionArgument(**argument), - context=context, - ), - ) + types.CompleteRequest( + params=types.CompleteRequestParams( + ref=ref, + argument=types.CompletionArgument(**argument), + context=context, + ), ), types.CompleteResult, ) - @overload - @deprecated("Use list_tools(params=PaginatedRequestParams(...)) instead") - async def list_tools(self, cursor: str | None) -> types.ListToolsResult: ... - - @overload - async def list_tools(self, *, params: types.PaginatedRequestParams | None) -> types.ListToolsResult: ... - - @overload - async def list_tools(self) -> types.ListToolsResult: ... - - async def list_tools( - self, - cursor: str | None = None, - *, - params: types.PaginatedRequestParams | None = None, - ) -> types.ListToolsResult: + async def list_tools(self, *, params: types.PaginatedRequestParams | None = None) -> types.ListToolsResult: """Send a tools/list request. Args: - cursor: Simple cursor string for pagination (deprecated, use params instead) params: Full pagination parameters including cursor and any future fields """ - if params is not None and cursor is not None: - raise ValueError("Cannot specify both cursor and params") - - if params is not None: - request_params = params - elif cursor is not None: - request_params = types.PaginatedRequestParams(cursor=cursor) - else: - request_params = None - result = await self.send_request( - types.ClientRequest(types.ListToolsRequest(params=request_params)), + types.ListToolsRequest(params=params), types.ListToolsResult, ) # Cache tool output schemas for future validation # Note: don't clear the cache, as we may be using a cursor for tool in result.tools: - self._tool_output_schemas[tool.name] = tool.outputSchema + self._tool_output_schemas[tool.name] = tool.output_schema return result async def send_roots_list_changed(self) -> None: # pragma: no cover """Send a roots/list_changed notification.""" - await self.send_notification(types.ClientNotification(types.RootsListChangedNotification())) + await self.send_notification(types.RootsListChangedNotification()) async def _received_request(self, responder: RequestResponder[types.ServerRequest, types.ClientResult]) -> None: - ctx = RequestContext[ClientSession, Any]( - request_id=responder.request_id, - meta=responder.request_meta, - session=self, - lifespan_context=None, - ) + ctx = RequestContext[ClientSession](request_id=responder.request_id, meta=responder.request_meta, session=self) # Delegate to experimental task handler if applicable if self._task_handlers.handles_request(responder.request): @@ -551,7 +421,7 @@ async def _received_request(self, responder: RequestResponder[types.ServerReques return None # Core request handling - match responder.request.root: + match responder.request: case types.CreateMessageRequest(params=params): with responder: # Check if this is a task-augmented request @@ -580,7 +450,7 @@ async def _received_request(self, responder: RequestResponder[types.ServerReques case types.PingRequest(): # pragma: no cover with responder: - return await responder.respond(types.ClientResult(root=types.EmptyResult())) + return await responder.respond(types.EmptyResult()) case _: # pragma: no cover pass # Task requests handled above by _task_handlers @@ -597,7 +467,7 @@ async def _handle_incoming( async def _received_notification(self, notification: types.ServerNotification) -> None: """Handle notifications from the server.""" # Process specific notification types - match notification.root: + match notification: case types.LoggingMessageNotification(params=params): await self._logging_callback(params) case types.ElicitCompleteNotification(params=params): diff --git a/src/mcp/client/session_group.py b/src/mcp/client/session_group.py index da45923e2..961021264 100644 --- a/src/mcp/client/session_group.py +++ b/src/mcp/client/session_group.py @@ -1,37 +1,36 @@ -""" -SessionGroup concurrently manages multiple MCP session connections. +"""SessionGroup concurrently manages multiple MCP session connections. Tools, resources, and prompts are aggregated across servers. Servers may be connected to or disconnected from at any point after initialization. -This abstractions can handle naming collisions using a custom user-provided -hook. +This abstraction can handle naming collisions using a custom user-provided hook. """ import contextlib import logging from collections.abc import Callable from dataclasses import dataclass -from datetime import timedelta from types import TracebackType -from typing import Any, TypeAlias, overload +from typing import Any, TypeAlias import anyio -from pydantic import BaseModel -from typing_extensions import Self, deprecated +import httpx +from pydantic import BaseModel, Field +from typing_extensions import Self import mcp from mcp import types from mcp.client.session import ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT from mcp.client.sse import sse_client from mcp.client.stdio import StdioServerParameters -from mcp.client.streamable_http import streamablehttp_client -from mcp.shared.exceptions import McpError +from mcp.client.streamable_http import streamable_http_client +from mcp.shared._httpx_utils import create_mcp_http_client +from mcp.shared.exceptions import MCPError from mcp.shared.session import ProgressFnT class SseServerParameters(BaseModel): - """Parameters for intializing a sse_client.""" + """Parameters for initializing an sse_client.""" # The endpoint URL. url: str @@ -39,15 +38,15 @@ class SseServerParameters(BaseModel): # Optional headers to include in requests. headers: dict[str, Any] | None = None - # HTTP timeout for regular operations. - timeout: float = 5 + # HTTP timeout for regular operations (in seconds). + timeout: float = 5.0 - # Timeout for SSE read operations. - sse_read_timeout: float = 60 * 5 + # Timeout for SSE read operations (in seconds). + sse_read_timeout: float = 300.0 class StreamableHttpParameters(BaseModel): - """Parameters for intializing a streamablehttp_client.""" + """Parameters for initializing a streamable_http_client.""" # The endpoint URL. url: str @@ -55,11 +54,11 @@ class StreamableHttpParameters(BaseModel): # Optional headers to include in requests. headers: dict[str, Any] | None = None - # HTTP timeout for regular operations. - timeout: timedelta = timedelta(seconds=30) + # HTTP timeout for regular operations (in seconds). + timeout: float = 30.0 - # Timeout for SSE read operations. - sse_read_timeout: timedelta = timedelta(seconds=60 * 5) + # Timeout for SSE read operations (in seconds). + sse_read_timeout: float = 300.0 # Close the client session when the transport closes. terminate_on_close: bool = True @@ -68,13 +67,13 @@ class StreamableHttpParameters(BaseModel): ServerParameters: TypeAlias = StdioServerParameters | SseServerParameters | StreamableHttpParameters -# Use dataclass instead of pydantic BaseModel -# because pydantic BaseModel cannot handle Protocol fields. +# Use dataclass instead of Pydantic BaseModel +# because Pydantic BaseModel cannot handle Protocol fields. @dataclass class ClientSessionParameters: """Parameters for establishing a client session to an MCP server.""" - read_timeout_seconds: timedelta | None = None + read_timeout_seconds: float | None = None sampling_callback: SamplingFnT | None = None elicitation_callback: ElicitationFnT | None = None list_roots_callback: ListRootsFnT | None = None @@ -92,21 +91,22 @@ class ClientSessionGroup: For auxiliary handlers, such as resource subscription, this is delegated to the client and can be accessed via the session. - Example Usage: + Example: + ```python name_fn = lambda name, server_info: f"{(server_info.name)}_{name}" async with ClientSessionGroup(component_name_hook=name_fn) as group: for server_param in server_params: await group.connect_to_server(server_param) ... - + ``` """ class _ComponentNames(BaseModel): """Used for reverse index to find components.""" - prompts: set[str] = set() - resources: set[str] = set() - tools: set[str] = set() + prompts: set[str] = Field(default_factory=set) + resources: set[str] = Field(default_factory=set) + tools: set[str] = Field(default_factory=set) # Standard MCP components. _prompts: dict[str, types.Prompt] @@ -119,9 +119,9 @@ class _ComponentNames(BaseModel): _exit_stack: contextlib.AsyncExitStack _session_exit_stacks: dict[mcp.ClientSession, contextlib.AsyncExitStack] - # Optional fn consuming (component_name, serverInfo) for custom names. - # This is provide a means to mitigate naming conflicts across servers. - # Example: (tool_name, serverInfo) => "{result.serverInfo.name}.{tool_name}" + # Optional fn consuming (component_name, server_info) for custom names. + # This is to provide a means to mitigate naming conflicts across servers. + # Example: (tool_name, server_info) => "{result.server_info.name}.{tool_name}" _ComponentNameHook: TypeAlias = Callable[[str, types.Implementation], str] _component_name_hook: _ComponentNameHook | None @@ -190,45 +190,21 @@ def tools(self) -> dict[str, types.Tool]: """Returns the tools as a dictionary of names to tools.""" return self._tools - @overload - async def call_tool( - self, - name: str, - arguments: dict[str, Any], - read_timeout_seconds: timedelta | None = None, - progress_callback: ProgressFnT | None = None, - *, - meta: dict[str, Any] | None = None, - ) -> types.CallToolResult: ... - - @overload - @deprecated("The 'args' parameter is deprecated. Use 'arguments' instead.") - async def call_tool( - self, - name: str, - *, - args: dict[str, Any], - read_timeout_seconds: timedelta | None = None, - progress_callback: ProgressFnT | None = None, - meta: dict[str, Any] | None = None, - ) -> types.CallToolResult: ... - async def call_tool( self, name: str, arguments: dict[str, Any] | None = None, - read_timeout_seconds: timedelta | None = None, + read_timeout_seconds: float | None = None, progress_callback: ProgressFnT | None = None, *, - meta: dict[str, Any] | None = None, - args: dict[str, Any] | None = None, + meta: types.RequestParamsMeta | None = None, ) -> types.CallToolResult: """Executes a tool given its name and arguments.""" session = self._tool_to_session[name] session_tool_name = self.tools[name].name return await session.call_tool( session_tool_name, - arguments if args is None else args, + arguments=arguments, read_timeout_seconds=read_timeout_seconds, progress_callback=progress_callback, meta=meta, @@ -241,29 +217,27 @@ async def disconnect_from_server(self, session: mcp.ClientSession) -> None: session_known_for_stack = session in self._session_exit_stacks if not session_known_for_components and not session_known_for_stack: - raise McpError( - types.ErrorData( - code=types.INVALID_PARAMS, - message="Provided session is not managed or already disconnected.", - ) + raise MCPError( + code=types.INVALID_PARAMS, + message="Provided session is not managed or already disconnected.", ) - if session_known_for_components: # pragma: no cover + if session_known_for_components: # pragma: no branch component_names = self._sessions.pop(session) # Pop from _sessions tracking # Remove prompts associated with the session. for name in component_names.prompts: - if name in self._prompts: + if name in self._prompts: # pragma: no branch del self._prompts[name] # Remove resources associated with the session. for name in component_names.resources: - if name in self._resources: + if name in self._resources: # pragma: no branch del self._resources[name] # Remove tools associated with the session. for name in component_names.tools: - if name in self._tools: + if name in self._tools: # pragma: no branch del self._tools[name] - if name in self._tool_to_session: + if name in self._tool_to_session: # pragma: no branch del self._tool_to_session[name] # Clean up the session's resources via its dedicated exit stack @@ -309,14 +283,21 @@ async def _establish_session( ) read, write = await session_stack.enter_async_context(client) else: - client = streamablehttp_client( - url=server_params.url, + httpx_client = create_mcp_http_client( headers=server_params.headers, - timeout=server_params.timeout, - sse_read_timeout=server_params.sse_read_timeout, + timeout=httpx.Timeout( + server_params.timeout, + read=server_params.sse_read_timeout, + ), + ) + await session_stack.enter_async_context(httpx_client) + + client = streamable_http_client( + url=server_params.url, + http_client=httpx_client, terminate_on_close=server_params.terminate_on_close, ) - read, write, _ = await session_stack.enter_async_context(client) + read, write = await session_stack.enter_async_context(client) session = await session_stack.enter_async_context( mcp.ClientSession( @@ -341,7 +322,7 @@ async def _establish_session( # main _exit_stack. await self._exit_stack.enter_async_context(session_stack) - return result.serverInfo, session + return result.server_info, session except Exception: # pragma: no cover # If anything during this setup fails, ensure the session-specific # stack is closed. @@ -370,7 +351,7 @@ async def _aggregate_components(self, server_info: types.Implementation, session name = self._component_name(prompt.name, server_info) prompts_temp[name] = prompt component_names.prompts.add(name) - except McpError as err: # pragma: no cover + except MCPError as err: # pragma: no cover logging.warning(f"Could not fetch prompts: {err}") # Query the server for its resources and aggregate to list. @@ -380,7 +361,7 @@ async def _aggregate_components(self, server_info: types.Implementation, session name = self._component_name(resource.name, server_info) resources_temp[name] = resource component_names.resources.add(name) - except McpError as err: # pragma: no cover + except MCPError as err: # pragma: no cover logging.warning(f"Could not fetch resources: {err}") # Query the server for its tools and aggregate to list. @@ -391,7 +372,7 @@ async def _aggregate_components(self, server_info: types.Implementation, session tools_temp[name] = tool tool_to_session_temp[name] = session component_names.tools.add(name) - except McpError as err: # pragma: no cover + except MCPError as err: # pragma: no cover logging.warning(f"Could not fetch tools: {err}") # Clean up exit stack for session if we couldn't retrieve anything @@ -402,28 +383,19 @@ async def _aggregate_components(self, server_info: types.Implementation, session # Check for duplicates. matching_prompts = prompts_temp.keys() & self._prompts.keys() if matching_prompts: - raise McpError( # pragma: no cover - types.ErrorData( - code=types.INVALID_PARAMS, - message=f"{matching_prompts} already exist in group prompts.", - ) + raise MCPError( # pragma: no cover + code=types.INVALID_PARAMS, + message=f"{matching_prompts} already exist in group prompts.", ) matching_resources = resources_temp.keys() & self._resources.keys() if matching_resources: - raise McpError( # pragma: no cover - types.ErrorData( - code=types.INVALID_PARAMS, - message=f"{matching_resources} already exist in group resources.", - ) + raise MCPError( # pragma: no cover + code=types.INVALID_PARAMS, + message=f"{matching_resources} already exist in group resources.", ) matching_tools = tools_temp.keys() & self._tools.keys() if matching_tools: - raise McpError( - types.ErrorData( - code=types.INVALID_PARAMS, - message=f"{matching_tools} already exist in group tools.", - ) - ) + raise MCPError(code=types.INVALID_PARAMS, message=f"{matching_tools} already exist in group tools.") # Aggregate components. self._sessions[session] = component_names diff --git a/src/mcp/client/sse.py b/src/mcp/client/sse.py index b2ac67744..61026aa0c 100644 --- a/src/mcp/client/sse.py +++ b/src/mcp/client/sse.py @@ -11,7 +11,7 @@ from httpx_sse import aconnect_sse from httpx_sse._exceptions import SSEError -import mcp.types as types +from mcp import types from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client from mcp.shared.message import SessionMessage @@ -31,14 +31,13 @@ def _extract_session_id_from_endpoint(endpoint_url: str) -> str | None: async def sse_client( url: str, headers: dict[str, Any] | None = None, - timeout: float = 5, - sse_read_timeout: float = 60 * 5, + timeout: float = 5.0, + sse_read_timeout: float = 300.0, httpx_client_factory: McpHttpClientFactory = create_mcp_http_client, auth: httpx.Auth | None = None, on_session_created: Callable[[str], None] | None = None, ): - """ - Client transport for SSE. + """Client transport for SSE. `sse_read_timeout` determines how long (in seconds) the client will wait for a new event before disconnecting. All other HTTP operations are controlled by `timeout`. @@ -46,8 +45,9 @@ async def sse_client( Args: url: The SSE endpoint URL. headers: Optional headers to include in requests. - timeout: HTTP timeout for regular operations. - sse_read_timeout: Timeout for SSE read operations. + timeout: HTTP timeout for regular operations (in seconds). + sse_read_timeout: Timeout for SSE read operations (in seconds). + httpx_client_factory: Factory function for creating the HTTPX client. auth: Optional HTTPX authentication handler. on_session_created: Optional callback invoked with the session ID when received. """ @@ -74,9 +74,7 @@ async def sse_client( event_source.response.raise_for_status() logger.debug("SSE connection established") - async def sse_reader( - task_status: TaskStatus[str] = anyio.TASK_STATUS_IGNORED, - ): + async def sse_reader(task_status: TaskStatus[str] = anyio.TASK_STATUS_IGNORED): try: async for sse in event_source.aiter_sse(): # pragma: no branch logger.debug(f"Received SSE event: {sse.event}") @@ -109,8 +107,8 @@ async def sse_reader( if not sse.data: continue try: - message = types.JSONRPCMessage.model_validate_json( # noqa: E501 - sse.data + message = types.jsonrpc_message_adapter.validate_json( + sse.data, by_name=False ) logger.debug(f"Received server message: {message}") except Exception as exc: # pragma: no cover @@ -122,12 +120,12 @@ async def sse_reader( await read_stream_writer.send(session_message) case _: # pragma: no cover logger.warning(f"Unknown SSE event: {sse.event}") # pragma: no cover - except SSEError as sse_exc: # pragma: no cover - logger.exception("Encountered SSE exception") # pragma: no cover - raise sse_exc # pragma: no cover - except Exception as exc: # pragma: no cover - logger.exception("Error in sse_reader") # pragma: no cover - await read_stream_writer.send(exc) # pragma: no cover + except SSEError as sse_exc: # pragma: lax no cover + logger.exception("Encountered SSE exception") + raise sse_exc + except Exception as exc: # pragma: lax no cover + logger.exception("Error in sse_reader") + await read_stream_writer.send(exc) finally: await read_stream_writer.aclose() @@ -141,13 +139,13 @@ async def post_writer(endpoint_url: str): json=session_message.message.model_dump( by_alias=True, mode="json", - exclude_none=True, + exclude_unset=True, ), ) response.raise_for_status() logger.debug(f"Client message sent successfully: {response.status_code}") - except Exception: # pragma: no cover - logger.exception("Error in post_writer") # pragma: no cover + except Exception: # pragma: lax no cover + logger.exception("Error in post_writer") finally: await write_stream.aclose() diff --git a/src/mcp/client/stdio/__init__.py b/src/mcp/client/stdio.py similarity index 89% rename from src/mcp/client/stdio/__init__.py rename to src/mcp/client/stdio.py index 0d76bb958..902dc8576 100644 --- a/src/mcp/client/stdio/__init__.py +++ b/src/mcp/client/stdio.py @@ -12,7 +12,7 @@ from anyio.streams.text import TextReceiveStream from pydantic import BaseModel, Field -import mcp.types as types +from mcp import types from mcp.os.posix.utilities import terminate_posix_process_tree from mcp.os.win32.utilities import ( FallbackProcess, @@ -49,16 +49,15 @@ def get_default_environment() -> dict[str, str]: - """ - Returns a default environment object including only environment variables deemed + """Returns a default environment object including only environment variables deemed safe to inherit. """ env: dict[str, str] = {} for key in DEFAULT_INHERITED_ENV_VARS: value = os.environ.get(key) - if value is None: - continue # pragma: no cover + if value is None: # pragma: lax no cover + continue if value.startswith("()"): # pragma: no cover # Skip functions, which are a security risk @@ -88,9 +87,9 @@ class StdioServerParameters(BaseModel): encoding: str = "utf-8" """ - The text encoding used when sending/receiving messages to the server + The text encoding used when sending/receiving messages to the server. - defaults to utf-8 + Defaults to utf-8. """ encoding_error_handler: Literal["strict", "ignore", "replace"] = "strict" @@ -98,14 +97,13 @@ class StdioServerParameters(BaseModel): The text encoding error handler. See https://docs.python.org/3/library/codecs.html#codec-base-classes for - explanations of possible values + explanations of possible values. """ @asynccontextmanager async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stderr): - """ - Client transport for stdio: this will connect to a server by spawning a + """Client transport for stdio: this will connect to a server by spawning a process and communicating with it over stdin/stdout. """ read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] @@ -152,7 +150,7 @@ async def stdout_reader(): for line in lines: try: - message = types.JSONRPCMessage.model_validate_json(line) + message = types.jsonrpc_message_adapter.validate_json(line, by_name=False) except Exception as exc: # pragma: no cover logger.exception("Failed to parse JSONRPC message from server") await read_stream_writer.send(exc) @@ -160,7 +158,7 @@ async def stdout_reader(): session_message = SessionMessage(message) await read_stream_writer.send(session_message) - except anyio.ClosedResourceError: # pragma: no cover + except anyio.ClosedResourceError: # pragma: lax no cover await anyio.lowlevel.checkpoint() async def stdin_writer(): @@ -169,7 +167,7 @@ async def stdin_writer(): try: async with write_stream_reader: async for session_message in write_stream_reader: - json = session_message.message.model_dump_json(by_alias=True, exclude_none=True) + json = session_message.message.model_dump_json(by_alias=True, exclude_unset=True) await process.stdin.send( (json + "\n").encode( encoding=server.encoding, @@ -179,10 +177,7 @@ async def stdin_writer(): except anyio.ClosedResourceError: # pragma: no cover await anyio.lowlevel.checkpoint() - async with ( - anyio.create_task_group() as tg, - process, - ): + async with anyio.create_task_group() as tg, process: tg.start_soon(stdout_reader) tg.start_soon(stdin_writer) try: @@ -217,8 +212,7 @@ async def stdin_writer(): def _get_executable_command(command: str) -> str: - """ - Get the correct executable command normalized for the current platform. + """Get the correct executable command normalized for the current platform. Args: command: Base command (e.g., 'uvx', 'npx') @@ -228,8 +222,8 @@ def _get_executable_command(command: str) -> str: """ if sys.platform == "win32": # pragma: no cover return get_windows_executable_command(command) - else: - return command # pragma: no cover + else: # pragma: lax no cover + return command async def _create_platform_compatible_process( @@ -239,29 +233,27 @@ async def _create_platform_compatible_process( errlog: TextIO = sys.stderr, cwd: Path | str | None = None, ): - """ - Creates a subprocess in a platform-compatible way. + """Creates a subprocess in a platform-compatible way. Unix: Creates process in a new session/process group for killpg support Windows: Creates process in a Job Object for reliable child termination """ if sys.platform == "win32": # pragma: no cover process = await create_windows_process(command, args, env, errlog, cwd) - else: + else: # pragma: lax no cover process = await anyio.open_process( [command, *args], env=env, stderr=errlog, cwd=cwd, start_new_session=True, - ) # pragma: no cover + ) return process async def _terminate_process_tree(process: Process | FallbackProcess, timeout_seconds: float = 2.0) -> None: - """ - Terminate a process and all its children using platform-specific methods. + """Terminate a process and all its children using platform-specific methods. Unix: Uses os.killpg() for atomic process group termination Windows: Uses Job Objects via pywin32 for reliable child process cleanup @@ -272,7 +264,7 @@ async def _terminate_process_tree(process: Process | FallbackProcess, timeout_se """ if sys.platform == "win32": # pragma: no cover await terminate_windows_process_tree(process, timeout_seconds) - else: # pragma: no cover + else: # pragma: lax no cover # FallbackProcess should only be used for Windows compatibility assert isinstance(process, Process) await terminate_posix_process_tree(process, timeout_seconds) diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index fa0524e6e..9f3dd5e0b 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -1,26 +1,27 @@ -""" -StreamableHTTP Client Transport Module +"""Implements StreamableHTTP transport for MCP clients.""" -This module implements the StreamableHTTP transport for MCP clients, -providing support for HTTP POST requests with optional SSE streaming responses -and session management. -""" +from __future__ import annotations as _annotations +import contextlib import logging from collections.abc import AsyncGenerator, Awaitable, Callable from contextlib import asynccontextmanager from dataclasses import dataclass -from datetime import timedelta import anyio import httpx from anyio.abc import TaskGroup from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from httpx_sse import EventSource, ServerSentEvent, aconnect_sse +from pydantic import ValidationError -from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client +from mcp.client._transport import TransportStreams +from mcp.shared._httpx_utils import create_mcp_http_client from mcp.shared.message import ClientMessageMetadata, SessionMessage from mcp.types import ( + INTERNAL_ERROR, + INVALID_REQUEST, + PARSE_ERROR, ErrorData, InitializeResult, JSONRPCError, @@ -29,15 +30,16 @@ JSONRPCRequest, JSONRPCResponse, RequestId, + jsonrpc_message_adapter, ) logger = logging.getLogger(__name__) +# TODO(Marcelo): Put the TransportStreams in a module under shared, so we can import here. SessionMessageOrError = SessionMessage | Exception StreamWriter = MemoryObjectSendStream[SessionMessageOrError] StreamReader = MemoryObjectReceiveStream[SessionMessage] -GetSessionIdCallback = Callable[[], str | None] MCP_SESSION_ID = "mcp-session-id" MCP_PROTOCOL_VERSION = "mcp-protocol-version" @@ -46,12 +48,6 @@ # Reconnection defaults DEFAULT_RECONNECTION_DELAY_MS = 1000 # 1 second fallback when server doesn't provide retry MAX_RECONNECTION_ATTEMPTS = 2 # Max retry attempts before giving up -CONTENT_TYPE = "content-type" -ACCEPT = "accept" - - -JSON = "application/json" -SSE = "text/event-stream" class StreamableHTTPError(Exception): @@ -67,52 +63,36 @@ class RequestContext: """Context for a request operation.""" client: httpx.AsyncClient - headers: dict[str, str] session_id: str | None session_message: SessionMessage metadata: ClientMessageMetadata | None read_stream_writer: StreamWriter - sse_read_timeout: float class StreamableHTTPTransport: """StreamableHTTP client transport implementation.""" - def __init__( - self, - url: str, - headers: dict[str, str] | None = None, - timeout: float | timedelta = 30, - sse_read_timeout: float | timedelta = 60 * 5, - auth: httpx.Auth | None = None, - ) -> None: + def __init__(self, url: str) -> None: """Initialize the StreamableHTTP transport. Args: url: The endpoint URL. - headers: Optional headers to include in requests. - timeout: HTTP timeout for regular operations. - sse_read_timeout: Timeout for SSE read operations. - auth: Optional HTTPX authentication handler. """ self.url = url - self.headers = headers or {} - self.timeout = timeout.total_seconds() if isinstance(timeout, timedelta) else timeout - self.sse_read_timeout = ( - sse_read_timeout.total_seconds() if isinstance(sse_read_timeout, timedelta) else sse_read_timeout - ) - self.auth = auth - self.session_id = None - self.protocol_version = None - self.request_headers = { - ACCEPT: f"{JSON}, {SSE}", - CONTENT_TYPE: JSON, - **self.headers, - } + self.session_id: str | None = None + self.protocol_version: str | None = None + + def _prepare_headers(self) -> dict[str, str]: + """Build MCP-specific request headers. - def _prepare_request_headers(self, base_headers: dict[str, str]) -> dict[str, str]: - """Update headers with session ID and protocol version if available.""" - headers = base_headers.copy() + These headers will be merged with the httpx.AsyncClient's default headers, + with these MCP-specific headers taking precedence. + """ + headers: dict[str, str] = { + "accept": "application/json, text/event-stream", + "content-type": "application/json", + } + # Add session headers if available if self.session_id: headers[MCP_SESSION_ID] = self.session_id if self.protocol_version: @@ -121,38 +101,30 @@ def _prepare_request_headers(self, base_headers: dict[str, str]) -> dict[str, st def _is_initialization_request(self, message: JSONRPCMessage) -> bool: """Check if the message is an initialization request.""" - return isinstance(message.root, JSONRPCRequest) and message.root.method == "initialize" + return isinstance(message, JSONRPCRequest) and message.method == "initialize" def _is_initialized_notification(self, message: JSONRPCMessage) -> bool: """Check if the message is an initialized notification.""" - return isinstance(message.root, JSONRPCNotification) and message.root.method == "notifications/initialized" + return isinstance(message, JSONRPCNotification) and message.method == "notifications/initialized" - def _maybe_extract_session_id_from_response( - self, - response: httpx.Response, - ) -> None: + def _maybe_extract_session_id_from_response(self, response: httpx.Response) -> None: """Extract and store session ID from response headers.""" new_session_id = response.headers.get(MCP_SESSION_ID) if new_session_id: self.session_id = new_session_id logger.info(f"Received session ID: {self.session_id}") - def _maybe_extract_protocol_version_from_message( - self, - message: JSONRPCMessage, - ) -> None: + def _maybe_extract_protocol_version_from_message(self, message: JSONRPCMessage) -> None: """Extract protocol version from initialization response message.""" - if isinstance(message.root, JSONRPCResponse) and message.root.result: # pragma: no branch + if isinstance(message, JSONRPCResponse) and message.result: # pragma: no branch try: # Parse the result as InitializeResult for type safety - init_result = InitializeResult.model_validate(message.root.result) - self.protocol_version = str(init_result.protocolVersion) + init_result = InitializeResult.model_validate(message.result, by_name=False) + self.protocol_version = str(init_result.protocol_version) logger.info(f"Negotiated protocol version: {self.protocol_version}") - except Exception as exc: # pragma: no cover - logger.warning( - f"Failed to parse initialization response as InitializeResult: {exc}" - ) # pragma: no cover - logger.warning(f"Raw result: {message.root.result}") + except Exception: # pragma: no cover + logger.warning("Failed to parse initialization response as InitializeResult", exc_info=True) + logger.warning(f"Raw result: {message.result}") async def _handle_sse_event( self, @@ -171,7 +143,7 @@ async def _handle_sse_event( await resumption_callback(sse.id) return False try: - message = JSONRPCMessage.model_validate_json(sse.data) + message = jsonrpc_message_adapter.validate_json(sse.data, by_name=False) logger.debug(f"SSE message: {message}") # Extract protocol version from initialization response @@ -179,8 +151,8 @@ async def _handle_sse_event( self._maybe_extract_protocol_version_from_message(message) # If this is a response and we have original_request_id, replace it - if original_request_id is not None and isinstance(message.root, JSONRPCResponse | JSONRPCError): - message.root.id = original_request_id + if original_request_id is not None and isinstance(message, JSONRPCResponse | JSONRPCError): + message.id = original_request_id session_message = SessionMessage(message) await read_stream_writer.send(session_message) @@ -191,21 +163,22 @@ async def _handle_sse_event( # If this is a response or error return True indicating completion # Otherwise, return False to continue listening - return isinstance(message.root, JSONRPCResponse | JSONRPCError) + return isinstance(message, JSONRPCResponse | JSONRPCError) except Exception as exc: # pragma: no cover logger.exception("Error parsing SSE message") + if original_request_id is not None: + error_data = ErrorData(code=PARSE_ERROR, message=f"Failed to parse SSE message: {exc}") + error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=original_request_id, error=error_data)) + await read_stream_writer.send(error_msg) + return True await read_stream_writer.send(exc) return False else: # pragma: no cover logger.warning(f"Unknown SSE event: {sse.event}") return False - async def handle_get_stream( - self, - client: httpx.AsyncClient, - read_stream_writer: StreamWriter, - ) -> None: + async def handle_get_stream(self, client: httpx.AsyncClient, read_stream_writer: StreamWriter) -> None: """Handle GET stream for server-initiated messages with auto-reconnect.""" last_event_id: str | None = None retry_interval_ms: int | None = None @@ -216,35 +189,29 @@ async def handle_get_stream( if not self.session_id: return - headers = self._prepare_request_headers(self.request_headers) + headers = self._prepare_headers() if last_event_id: - headers[LAST_EVENT_ID] = last_event_id # pragma: no cover + headers[LAST_EVENT_ID] = last_event_id - async with aconnect_sse( - client, - "GET", - self.url, - headers=headers, - timeout=httpx.Timeout(self.timeout, read=self.sse_read_timeout), - ) as event_source: + async with aconnect_sse(client, "GET", self.url, headers=headers) as event_source: event_source.response.raise_for_status() logger.debug("GET SSE connection established") async for sse in event_source.aiter_sse(): # Track last event ID for reconnection if sse.id: - last_event_id = sse.id # pragma: no cover + last_event_id = sse.id # Track retry interval from server if sse.retry is not None: - retry_interval_ms = sse.retry # pragma: no cover + retry_interval_ms = sse.retry await self._handle_sse_event(sse, read_stream_writer) # Stream ended normally (server closed) - reset attempt counter attempt = 0 - except Exception as exc: # pragma: no cover - logger.debug(f"GET stream error: {exc}") + except Exception: # pragma: lax no cover + logger.debug("GET stream error", exc_info=True) attempt += 1 if attempt >= MAX_RECONNECTION_ATTEMPTS: # pragma: no cover @@ -258,7 +225,7 @@ async def handle_get_stream( async def _handle_resumption_request(self, ctx: RequestContext) -> None: """Handle a resumption request using GET with SSE.""" - headers = self._prepare_request_headers(ctx.headers) + headers = self._prepare_headers() if ctx.metadata and ctx.metadata.resumption_token: headers[LAST_EVENT_ID] = ctx.metadata.resumption_token else: @@ -266,16 +233,10 @@ async def _handle_resumption_request(self, ctx: RequestContext) -> None: # Extract original request ID to map responses original_request_id = None - if isinstance(ctx.session_message.message.root, JSONRPCRequest): # pragma: no branch - original_request_id = ctx.session_message.message.root.id + if isinstance(ctx.session_message.message, JSONRPCRequest): # pragma: no branch + original_request_id = ctx.session_message.message.id - async with aconnect_sse( - ctx.client, - "GET", - self.url, - headers=headers, - timeout=httpx.Timeout(self.timeout, read=self.sse_read_timeout), - ) as event_source: + async with aconnect_sse(ctx.client, "GET", self.url, headers=headers) as event_source: event_source.response.raise_for_status() logger.debug("Resumption GET SSE connection established") @@ -292,14 +253,14 @@ async def _handle_resumption_request(self, ctx: RequestContext) -> None: async def _handle_post_request(self, ctx: RequestContext) -> None: """Handle a POST request with response processing.""" - headers = self._prepare_request_headers(ctx.headers) + headers = self._prepare_headers() message = ctx.session_message.message is_initialization = self._is_initialization_request(message) async with ctx.client.stream( "POST", self.url, - json=message.model_dump(by_alias=True, mode="json", exclude_none=True), + json=message.model_dump(by_alias=True, mode="json", exclude_unset=True), headers=headers, ) as response: if response.status_code == 202: @@ -307,41 +268,50 @@ async def _handle_post_request(self, ctx: RequestContext) -> None: return if response.status_code == 404: # pragma: no branch - if isinstance(message.root, JSONRPCRequest): - await self._send_session_terminated_error( # pragma: no cover - ctx.read_stream_writer, # pragma: no cover - message.root.id, # pragma: no cover - ) # pragma: no cover - return # pragma: no cover - - response.raise_for_status() + if isinstance(message, JSONRPCRequest): # pragma: no branch + error_data = ErrorData(code=INVALID_REQUEST, message="Session terminated") + session_message = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data)) + await ctx.read_stream_writer.send(session_message) + return + + if response.status_code >= 400: + if isinstance(message, JSONRPCRequest): + error_data = ErrorData(code=INTERNAL_ERROR, message="Server returned an error response") + session_message = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data)) + await ctx.read_stream_writer.send(session_message) + return + if is_initialization: self._maybe_extract_session_id_from_response(response) # Per https://modelcontextprotocol.io/specification/2025-06-18/basic#notifications: # The server MUST NOT send a response to notifications. - if isinstance(message.root, JSONRPCRequest): - content_type = response.headers.get(CONTENT_TYPE, "").lower() - if content_type.startswith(JSON): - await self._handle_json_response(response, ctx.read_stream_writer, is_initialization) - elif content_type.startswith(SSE): + if isinstance(message, JSONRPCRequest): + content_type = response.headers.get("content-type", "").lower() + if content_type.startswith("application/json"): + await self._handle_json_response( + response, ctx.read_stream_writer, is_initialization, request_id=message.id + ) + elif content_type.startswith("text/event-stream"): await self._handle_sse_response(response, ctx, is_initialization) else: - await self._handle_unexpected_content_type( # pragma: no cover - content_type, # pragma: no cover - ctx.read_stream_writer, # pragma: no cover - ) # pragma: no cover + logger.error(f"Unexpected content type: {content_type}") + error_data = ErrorData(code=INVALID_REQUEST, message=f"Unexpected content type: {content_type}") + error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data)) + await ctx.read_stream_writer.send(error_msg) async def _handle_json_response( self, response: httpx.Response, read_stream_writer: StreamWriter, is_initialization: bool = False, + *, + request_id: RequestId, ) -> None: """Handle JSON response from the server.""" try: content = await response.aread() - message = JSONRPCMessage.model_validate_json(content) + message = jsonrpc_message_adapter.validate_json(content, by_name=False) # Extract protocol version from initialization response if is_initialization: @@ -349,9 +319,11 @@ async def _handle_json_response( session_message = SessionMessage(message) await read_stream_writer.send(session_message) - except Exception as exc: # pragma: no cover + except (httpx.StreamError, ValidationError) as exc: logger.exception("Error parsing JSON response") - await read_stream_writer.send(exc) + error_data = ErrorData(code=PARSE_ERROR, message=f"Failed to parse JSON response: {exc}") + error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=request_id, error=error_data)) + await read_stream_writer.send(error_msg) async def _handle_sse_response( self, @@ -363,6 +335,11 @@ async def _handle_sse_response( last_event_id: str | None = None retry_interval_ms: int | None = None + # The caller (_handle_post_request) only reaches here inside + # isinstance(message, JSONRPCRequest), so this is always a JSONRPCRequest. + assert isinstance(ctx.session_message.message, JSONRPCRequest) + original_request_id = ctx.session_message.message.id + try: event_source = EventSource(response) async for sse in event_source.aiter_sse(): # pragma: no branch @@ -377,16 +354,17 @@ async def _handle_sse_response( is_complete = await self._handle_sse_event( sse, ctx.read_stream_writer, + original_request_id=original_request_id, resumption_callback=(ctx.metadata.on_resumption_token_update if ctx.metadata else None), is_initialization=is_initialization, ) - # If the SSE event indicates completion, like returning respose/error + # If the SSE event indicates completion, like returning response/error # break the loop if is_complete: await response.aclose() return # Normal completion, no reconnect needed - except Exception as e: # pragma: no cover - logger.debug(f"SSE stream ended: {e}") + except Exception: + logger.debug("SSE stream ended", exc_info=True) # pragma: no cover # Stream ended without response - reconnect if we received an event with ID if last_event_id is not None: # pragma: no branch @@ -410,22 +388,16 @@ async def _handle_reconnection( delay_ms = retry_interval_ms if retry_interval_ms is not None else DEFAULT_RECONNECTION_DELAY_MS await anyio.sleep(delay_ms / 1000.0) - headers = self._prepare_request_headers(ctx.headers) + headers = self._prepare_headers() headers[LAST_EVENT_ID] = last_event_id # Extract original request ID to map responses original_request_id = None - if isinstance(ctx.session_message.message.root, JSONRPCRequest): # pragma: no branch - original_request_id = ctx.session_message.message.root.id + if isinstance(ctx.session_message.message, JSONRPCRequest): # pragma: no branch + original_request_id = ctx.session_message.message.id try: - async with aconnect_sse( - ctx.client, - "GET", - self.url, - headers=headers, - timeout=httpx.Timeout(self.timeout, read=self.sse_read_timeout), - ) as event_source: + async with aconnect_sse(ctx.client, "GET", self.url, headers=headers) as event_source: event_source.response.raise_for_status() logger.info("Reconnected to SSE stream") @@ -457,30 +429,6 @@ async def _handle_reconnection( # Try to reconnect again if we still have an event ID await self._handle_reconnection(ctx, last_event_id, retry_interval_ms, attempt + 1) - async def _handle_unexpected_content_type( - self, - content_type: str, - read_stream_writer: StreamWriter, - ) -> None: # pragma: no cover - """Handle unexpected content type in response.""" - error_msg = f"Unexpected content type: {content_type}" # pragma: no cover - logger.error(error_msg) # pragma: no cover - await read_stream_writer.send(ValueError(error_msg)) # pragma: no cover - - async def _send_session_terminated_error( - self, - read_stream_writer: StreamWriter, - request_id: RequestId, - ) -> None: - """Send a session terminated error response.""" - jsonrpc_error = JSONRPCError( - jsonrpc="2.0", - id=request_id, - error=ErrorData(code=32600, message="Session terminated"), - ) - session_message = SessionMessage(JSONRPCMessage(jsonrpc_error)) - await read_stream_writer.send(session_message) - async def post_writer( self, client: httpx.AsyncClient, @@ -512,12 +460,10 @@ async def post_writer( ctx = RequestContext( client=client, - headers=self.request_headers, session_id=self.session_id, session_message=session_message, metadata=metadata, read_stream_writer=read_stream_writer, - sse_read_timeout=self.sse_read_timeout, ) async def handle_request_async(): @@ -527,82 +473,88 @@ async def handle_request_async(): await self._handle_post_request(ctx) # If this is a request, start a new task to handle it - if isinstance(message.root, JSONRPCRequest): + if isinstance(message, JSONRPCRequest): tg.start_soon(handle_request_async) else: await handle_request_async() - except Exception: - logger.exception("Error in post_writer") # pragma: no cover + except Exception: # pragma: lax no cover + logger.exception("Error in post_writer") finally: await read_stream_writer.aclose() await write_stream.aclose() - async def terminate_session(self, client: httpx.AsyncClient) -> None: # pragma: no cover + async def terminate_session(self, client: httpx.AsyncClient) -> None: """Terminate the session by sending a DELETE request.""" - if not self.session_id: + if not self.session_id: # pragma: lax no cover return try: - headers = self._prepare_request_headers(self.request_headers) + headers = self._prepare_headers() response = await client.delete(self.url, headers=headers) - if response.status_code == 405: + if response.status_code == 405: # pragma: lax no cover logger.debug("Server does not allow session termination") - elif response.status_code not in (200, 204): + elif response.status_code not in (200, 204): # pragma: lax no cover logger.warning(f"Session termination failed: {response.status_code}") - except Exception as exc: + except Exception as exc: # pragma: no cover logger.warning(f"Session termination failed: {exc}") + # TODO(Marcelo): Check the TODO below, and cover this with tests if necessary. def get_session_id(self) -> str | None: """Get the current session ID.""" - return self.session_id + return self.session_id # pragma: no cover +# TODO(Marcelo): I've dropped the `get_session_id` callback because it breaks the Transport protocol. Is that needed? +# It's a completely wrong abstraction, so removal is a good idea. But if we need the client to find the session ID, +# we should think about a better way to do it. I believe we can achieve it with other means. @asynccontextmanager -async def streamablehttp_client( +async def streamable_http_client( url: str, - headers: dict[str, str] | None = None, - timeout: float | timedelta = 30, - sse_read_timeout: float | timedelta = 60 * 5, + *, + http_client: httpx.AsyncClient | None = None, terminate_on_close: bool = True, - httpx_client_factory: McpHttpClientFactory = create_mcp_http_client, - auth: httpx.Auth | None = None, -) -> AsyncGenerator[ - tuple[ - MemoryObjectReceiveStream[SessionMessage | Exception], - MemoryObjectSendStream[SessionMessage], - GetSessionIdCallback, - ], - None, -]: - """ - Client transport for StreamableHTTP. +) -> AsyncGenerator[TransportStreams, None]: + """Client transport for StreamableHTTP. - `sse_read_timeout` determines how long (in seconds) the client will wait for a new - event before disconnecting. All other HTTP operations are controlled by `timeout`. + Args: + url: The MCP server endpoint URL. + http_client: Optional pre-configured httpx.AsyncClient. If None, a default + client with recommended MCP timeouts will be created. To configure headers, + authentication, or other HTTP settings, create an httpx.AsyncClient and pass it here. + terminate_on_close: If True, send a DELETE request to terminate the session when the context exits. Yields: Tuple containing: - read_stream: Stream for reading messages from the server - write_stream: Stream for sending messages to the server - - get_session_id_callback: Function to retrieve the current session ID - """ - transport = StreamableHTTPTransport(url, headers, timeout, sse_read_timeout, auth) + Example: + See examples/snippets/clients/ for usage patterns. + """ read_stream_writer, read_stream = anyio.create_memory_object_stream[SessionMessage | Exception](0) write_stream, write_stream_reader = anyio.create_memory_object_stream[SessionMessage](0) + # Determine if we need to create and manage the client + client_provided = http_client is not None + client = http_client + + if client is None: + # Create default client with recommended MCP timeouts + client = create_mcp_http_client() + + transport = StreamableHTTPTransport(url) + async with anyio.create_task_group() as tg: try: logger.debug(f"Connecting to StreamableHTTP endpoint: {url}") - async with httpx_client_factory( - headers=transport.request_headers, - timeout=httpx.Timeout(transport.timeout, read=transport.sse_read_timeout), - auth=transport.auth, - ) as client: - # Define callbacks that need access to tg + async with contextlib.AsyncExitStack() as stack: + # Only manage client lifecycle if we created it + if not client_provided: + await stack.enter_async_context(client) + def start_get_stream() -> None: tg.start_soon(transport.handle_get_stream, client, read_stream_writer) @@ -617,11 +569,7 @@ def start_get_stream() -> None: ) try: - yield ( - read_stream, - write_stream, - transport.get_session_id, - ) + yield read_stream, write_stream finally: if transport.session_id and terminate_on_close: await transport.terminate_session(client) diff --git a/src/mcp/client/websocket.py b/src/mcp/client/websocket.py index e8c8d9af8..79e75fad1 100644 --- a/src/mcp/client/websocket.py +++ b/src/mcp/client/websocket.py @@ -1,5 +1,4 @@ import json -import logging from collections.abc import AsyncGenerator from contextlib import asynccontextmanager @@ -9,11 +8,9 @@ from websockets.asyncio.client import connect as ws_connect from websockets.typing import Subprotocol -import mcp.types as types +from mcp import types from mcp.shared.message import SessionMessage -logger = logging.getLogger(__name__) - @asynccontextmanager async def websocket_client( @@ -22,15 +19,14 @@ async def websocket_client( tuple[MemoryObjectReceiveStream[SessionMessage | Exception], MemoryObjectSendStream[SessionMessage]], None, ]: - """ - WebSocket client transport for MCP, symmetrical to the server version. + """WebSocket client transport for MCP, symmetrical to the server version. Connects to 'url' using the 'mcp' subprotocol, then yields: (read_stream, write_stream) - read_stream: As you read from this stream, you'll receive either valid - JSONRPCMessage objects or Exception objects (when validation fails). - - write_stream: Write JSONRPCMessage objects to this stream to send them + SessionMessage objects or Exception objects (when validation fails). + - write_stream: Write SessionMessage objects to this stream to send them over the WebSocket to the server. """ @@ -49,14 +45,13 @@ async def websocket_client( async with ws_connect(url, subprotocols=[Subprotocol("mcp")]) as ws: async def ws_reader(): - """ - Reads text messages from the WebSocket, parses them as JSON-RPC messages, + """Reads text messages from the WebSocket, parses them as JSON-RPC messages, and sends them into read_stream_writer. """ async with read_stream_writer: async for raw_text in ws: try: - message = types.JSONRPCMessage.model_validate_json(raw_text) + message = types.jsonrpc_message_adapter.validate_json(raw_text, by_name=False) session_message = SessionMessage(message) await read_stream_writer.send(session_message) except ValidationError as exc: # pragma: no cover @@ -64,14 +59,13 @@ async def ws_reader(): await read_stream_writer.send(exc) async def ws_writer(): - """ - Reads JSON-RPC messages from write_stream_reader and + """Reads JSON-RPC messages from write_stream_reader and sends them to the server. """ async with write_stream_reader: async for session_message in write_stream_reader: # Convert to a dict, then to JSON - msg_dict = session_message.message.model_dump(by_alias=True, mode="json", exclude_none=True) + msg_dict = session_message.message.model_dump(by_alias=True, mode="json", exclude_unset=True) await ws.send(json.dumps(msg_dict)) async with anyio.create_task_group() as tg: diff --git a/src/mcp/os/posix/utilities.py b/src/mcp/os/posix/utilities.py index dd1aea363..0e9d74cf3 100644 --- a/src/mcp/os/posix/utilities.py +++ b/src/mcp/os/posix/utilities.py @@ -1,6 +1,4 @@ -""" -POSIX-specific functionality for stdio client operations. -""" +"""POSIX-specific functionality for stdio client operations.""" import logging import os @@ -13,8 +11,7 @@ async def terminate_posix_process_tree(process: Process, timeout_seconds: float = 2.0) -> None: - """ - Terminate a process and all its children on POSIX systems. + """Terminate a process and all its children on POSIX systems. Uses os.killpg() for atomic process group termination. diff --git a/src/mcp/os/win32/utilities.py b/src/mcp/os/win32/utilities.py index 962be0229..0e188691f 100644 --- a/src/mcp/os/win32/utilities.py +++ b/src/mcp/os/win32/utilities.py @@ -1,6 +1,4 @@ -""" -Windows-specific functionality for stdio client operations. -""" +"""Windows-specific functionality for stdio client operations.""" import logging import shutil @@ -34,8 +32,7 @@ def get_windows_executable_command(command: str) -> str: - """ - Get the correct executable command normalized for Windows. + """Get the correct executable command normalized for Windows. On Windows, commands might exist with specific extensions (.exe, .cmd, etc.) that need to be located for proper execution. @@ -66,8 +63,7 @@ def get_windows_executable_command(command: str) -> str: class FallbackProcess: - """ - A fallback process wrapper for Windows to handle async I/O + """A fallback process wrapper for Windows to handle async I/O when using subprocess.Popen, which provides sync-only FileIO objects. This wraps stdin and stdout into async-compatible @@ -140,12 +136,11 @@ async def create_windows_process( errlog: TextIO | None = sys.stderr, cwd: Path | str | None = None, ) -> Process | FallbackProcess: - """ - Creates a subprocess in a Windows-compatible way with Job Object support. + """Creates a subprocess in a Windows-compatible way with Job Object support. - Attempt to use anyio's open_process for async subprocess creation. - In some cases this will throw NotImplementedError on Windows, e.g. - when using the SelectorEventLoop which does not support async subprocesses. + Attempts to use anyio's open_process for async subprocess creation. + In some cases this will throw NotImplementedError on Windows, e.g., + when using the SelectorEventLoop, which does not support async subprocesses. In that case, we fall back to using subprocess.Popen. The process is automatically added to a Job Object to ensure all child @@ -199,8 +194,7 @@ async def _create_windows_fallback_process( errlog: TextIO | None = sys.stderr, cwd: Path | str | None = None, ) -> FallbackProcess: - """ - Create a subprocess using subprocess.Popen as a fallback when anyio fails. + """Create a subprocess using subprocess.Popen as a fallback when anyio fails. This function wraps the sync subprocess.Popen in an async-compatible interface. """ @@ -231,9 +225,7 @@ async def _create_windows_fallback_process( def _create_job_object() -> int | None: - """ - Create a Windows Job Object configured to terminate all processes when closed. - """ + """Create a Windows Job Object configured to terminate all processes when closed.""" if sys.platform != "win32" or not win32job: return None @@ -250,9 +242,9 @@ def _create_job_object() -> int | None: def _maybe_assign_process_to_job(process: Process | FallbackProcess, job: JobHandle | None) -> None: - """ - Try to assign a process to a job object. If assignment fails - for any reason, the job handle is closed. + """Try to assign a process to a job object. + + If assignment fails for any reason, the job handle is closed. """ if not job: return @@ -279,8 +271,7 @@ def _maybe_assign_process_to_job(process: Process | FallbackProcess, job: JobHan async def terminate_windows_process_tree(process: Process | FallbackProcess, timeout_seconds: float = 2.0) -> None: - """ - Terminate a process and all its children on Windows. + """Terminate a process and all its children on Windows. If the process has an associated job object, it will be terminated. Otherwise, falls back to basic process termination. @@ -318,13 +309,12 @@ async def terminate_windows_process_tree(process: Process | FallbackProcess, tim "Process termination is now handled internally by the stdio_client context manager." ) async def terminate_windows_process(process: Process | FallbackProcess): - """ - Terminate a Windows process. + """Terminate a Windows process. Note: On Windows, terminating a process with process.terminate() doesn't always guarantee immediate process termination. - So we give it 2s to exit, or we call process.kill() - which sends a SIGKILL equivalent signal. + If the process does not exit within 2 seconds, process.kill() is called + to send a SIGKILL-equivalent signal. Args: process: The process to terminate diff --git a/src/mcp/server/__init__.py b/src/mcp/server/__init__.py index 0feed368e..aab5c33f7 100644 --- a/src/mcp/server/__init__.py +++ b/src/mcp/server/__init__.py @@ -1,5 +1,6 @@ -from .fastmcp import FastMCP +from .context import ServerRequestContext from .lowlevel import NotificationOptions, Server +from .mcpserver import MCPServer from .models import InitializationOptions -__all__ = ["Server", "FastMCP", "NotificationOptions", "InitializationOptions"] +__all__ = ["Server", "ServerRequestContext", "MCPServer", "NotificationOptions", "InitializationOptions"] diff --git a/src/mcp/server/__main__.py b/src/mcp/server/__main__.py index 1970eca7d..dbc50b8a7 100644 --- a/src/mcp/server/__main__.py +++ b/src/mcp/server/__main__.py @@ -1,6 +1,7 @@ import importlib.metadata import logging import sys +import warnings import anyio @@ -10,8 +11,6 @@ from mcp.types import ServerCapabilities if not sys.warnoptions: - import warnings - warnings.simplefilter("ignore") logging.basicConfig(level=logging.INFO) diff --git a/src/mcp/server/auth/__init__.py b/src/mcp/server/auth/__init__.py index 6888ffe8d..61b60e348 100644 --- a/src/mcp/server/auth/__init__.py +++ b/src/mcp/server/auth/__init__.py @@ -1,3 +1 @@ -""" -MCP OAuth server authorization components. -""" +"""MCP OAuth server authorization components.""" diff --git a/src/mcp/server/auth/handlers/__init__.py b/src/mcp/server/auth/handlers/__init__.py index e99a62de1..fd8a462b3 100644 --- a/src/mcp/server/auth/handlers/__init__.py +++ b/src/mcp/server/auth/handlers/__init__.py @@ -1,3 +1 @@ -""" -Request handlers for MCP authorization endpoints. -""" +"""Request handlers for MCP authorization endpoints.""" diff --git a/src/mcp/server/auth/handlers/authorize.py b/src/mcp/server/auth/handlers/authorize.py index 3570d28c2..dec6713b1 100644 --- a/src/mcp/server/auth/handlers/authorize.py +++ b/src/mcp/server/auth/handlers/authorize.py @@ -2,7 +2,8 @@ from dataclasses import dataclass from typing import Any, Literal -from pydantic import AnyUrl, BaseModel, Field, RootModel, ValidationError +# TODO(Marcelo): We should drop the `RootModel`. +from pydantic import AnyUrl, BaseModel, Field, RootModel, ValidationError # noqa: TID251 from starlette.datastructures import FormData, QueryParams from starlette.requests import Request from starlette.responses import RedirectResponse, Response diff --git a/src/mcp/server/auth/handlers/register.py b/src/mcp/server/auth/handlers/register.py index c65473d1f..79eb0fb0c 100644 --- a/src/mcp/server/auth/handlers/register.py +++ b/src/mcp/server/auth/handlers/register.py @@ -4,7 +4,7 @@ from typing import Any from uuid import uuid4 -from pydantic import BaseModel, RootModel, ValidationError +from pydantic import BaseModel, ValidationError from starlette.requests import Request from starlette.responses import Response @@ -14,11 +14,9 @@ from mcp.server.auth.settings import ClientRegistrationOptions from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata - -class RegistrationRequest(RootModel[OAuthClientMetadata]): - # this wrapper is a no-op; it's just to separate out the types exposed to the - # provider from what we use in the HTTP handler - root: OAuthClientMetadata +# this alias is a no-op; it's just to separate out the types exposed to the +# provider from what we use in the HTTP handler +RegistrationRequest = OAuthClientMetadata class RegistrationErrorResponse(BaseModel): @@ -34,9 +32,8 @@ class RegistrationHandler: async def handle(self, request: Request) -> Response: # Implements dynamic client registration as defined in https://datatracker.ietf.org/doc/html/rfc7591#section-3.1 try: - # Parse request body as JSON - body = await request.json() - client_metadata = OAuthClientMetadata.model_validate(body) + body = await request.body() + client_metadata = OAuthClientMetadata.model_validate_json(body) # Scope validation is handled below except ValidationError as validation_error: @@ -73,11 +70,11 @@ async def handle(self, request: Request) -> Response: ), status_code=400, ) - if not {"authorization_code", "refresh_token"}.issubset(set(client_metadata.grant_types)): + if "authorization_code" not in client_metadata.grant_types: return PydanticJSONResponse( content=RegistrationErrorResponse( error="invalid_client_metadata", - error_description="grant_types must be authorization_code and refresh_token", + error_description="grant_types must include 'authorization_code'", ), status_code=400, ) diff --git a/src/mcp/server/auth/handlers/revoke.py b/src/mcp/server/auth/handlers/revoke.py index fa8cfc99d..4efd15400 100644 --- a/src/mcp/server/auth/handlers/revoke.py +++ b/src/mcp/server/auth/handlers/revoke.py @@ -15,9 +15,7 @@ class RevocationRequest(BaseModel): - """ - # See https://datatracker.ietf.org/doc/html/rfc7009#section-2.1 - """ + """See https://datatracker.ietf.org/doc/html/rfc7009#section-2.1""" token: str token_type_hint: Literal["access_token", "refresh_token"] | None = None @@ -36,9 +34,7 @@ class RevocationHandler: client_authenticator: ClientAuthenticator async def handle(self, request: Request) -> Response: - """ - Handler for the OAuth 2.0 Token Revocation endpoint. - """ + """Handler for the OAuth 2.0 Token Revocation endpoint.""" try: client = await self.client_authenticator.authenticate_request(request) except AuthenticationError as e: # pragma: no cover diff --git a/src/mcp/server/auth/handlers/token.py b/src/mcp/server/auth/handlers/token.py index 4467da617..534a478a9 100644 --- a/src/mcp/server/auth/handlers/token.py +++ b/src/mcp/server/auth/handlers/token.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import Annotated, Any, Literal -from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, RootModel, ValidationError +from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, TypeAdapter, ValidationError from starlette.requests import Request from mcp.server.auth.errors import stringify_pydantic_error @@ -40,35 +40,22 @@ class RefreshTokenRequest(BaseModel): resource: str | None = Field(None, description="Resource indicator for the token") -class TokenRequest( - RootModel[ - Annotated[ - AuthorizationCodeRequest | RefreshTokenRequest, - Field(discriminator="grant_type"), - ] - ] -): - root: Annotated[ - AuthorizationCodeRequest | RefreshTokenRequest, - Field(discriminator="grant_type"), - ] +TokenRequest = Annotated[AuthorizationCodeRequest | RefreshTokenRequest, Field(discriminator="grant_type")] +token_request_adapter = TypeAdapter[TokenRequest](TokenRequest) class TokenErrorResponse(BaseModel): - """ - See https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 - """ + """See https://datatracker.ietf.org/doc/html/rfc6749#section-5.2""" error: TokenErrorCode error_description: str | None = None error_uri: AnyHttpUrl | None = None -class TokenSuccessResponse(RootModel[OAuthToken]): - # this is just a wrapper over OAuthToken; the only reason we do this - # is to have some separation between the HTTP response type, and the - # type returned by the provider - root: OAuthToken +# this is just an alias over OAuthToken; the only reason we do this +# is to have some separation between the HTTP response type, and the +# type returned by the provider +TokenSuccessResponse = OAuthToken @dataclass @@ -97,7 +84,7 @@ async def handle(self, request: Request): # Authentication failures should return 401 return PydanticJSONResponse( content=TokenErrorResponse( - error="unauthorized_client", + error="invalid_client", error_description=e.message, ), status_code=401, @@ -109,7 +96,8 @@ async def handle(self, request: Request): try: form_data = await request.form() - token_request = TokenRequest.model_validate(dict(form_data)).root + # TODO(Marcelo): Can someone check if this `dict()` wrapper is necessary? + token_request = token_request_adapter.validate_python(dict(form_data)) except ValidationError as validation_error: # pragma: no cover return self.response( TokenErrorResponse( @@ -188,14 +176,9 @@ async def handle(self, request: Request): # Exchange authorization code for tokens tokens = await self.provider.exchange_authorization_code(client_info, auth_code) except TokenError as e: - return self.response( - TokenErrorResponse( - error=e.error, - error_description=e.error_description, - ) - ) + return self.response(TokenErrorResponse(error=e.error, error_description=e.error_description)) - case RefreshTokenRequest(): # pragma: no cover + case RefreshTokenRequest(): # pragma: no branch refresh_token = await self.provider.load_refresh_token(client_info, token_request.refresh_token) if refresh_token is None or refresh_token.client_id != token_request.client_id: # if token belongs to different client, pretend it doesn't exist @@ -231,11 +214,6 @@ async def handle(self, request: Request): # Exchange refresh token for new tokens tokens = await self.provider.exchange_refresh_token(client_info, refresh_token, scopes) except TokenError as e: - return self.response( - TokenErrorResponse( - error=e.error, - error_description=e.error_description, - ) - ) + return self.response(TokenErrorResponse(error=e.error, error_description=e.error_description)) - return self.response(TokenSuccessResponse(root=tokens)) + return self.response(tokens) diff --git a/src/mcp/server/auth/middleware/__init__.py b/src/mcp/server/auth/middleware/__init__.py index ba3ff63c3..ab07d8416 100644 --- a/src/mcp/server/auth/middleware/__init__.py +++ b/src/mcp/server/auth/middleware/__init__.py @@ -1,3 +1 @@ -""" -Middleware for MCP authorization. -""" +"""Middleware for MCP authorization.""" diff --git a/src/mcp/server/auth/middleware/auth_context.py b/src/mcp/server/auth/middleware/auth_context.py index e2116c3bf..1d34a5546 100644 --- a/src/mcp/server/auth/middleware/auth_context.py +++ b/src/mcp/server/auth/middleware/auth_context.py @@ -11,8 +11,7 @@ def get_access_token() -> AccessToken | None: - """ - Get the access token from the current context. + """Get the access token from the current context. Returns: The access token if an authenticated user is available, None otherwise. @@ -22,8 +21,7 @@ def get_access_token() -> AccessToken | None: class AuthContextMiddleware: - """ - Middleware that extracts the authenticated user from the request + """Middleware that extracts the authenticated user from the request and sets it in a contextvar for easy access throughout the request lifecycle. This middleware should be added after the AuthenticationMiddleware in the diff --git a/src/mcp/server/auth/middleware/bearer_auth.py b/src/mcp/server/auth/middleware/bearer_auth.py index 64c9b8841..6825c00b9 100644 --- a/src/mcp/server/auth/middleware/bearer_auth.py +++ b/src/mcp/server/auth/middleware/bearer_auth.py @@ -20,9 +20,7 @@ def __init__(self, auth_info: AccessToken): class BearerAuthBackend(AuthenticationBackend): - """ - Authentication backend that validates Bearer tokens using a TokenVerifier. - """ + """Authentication backend that validates Bearer tokens using a TokenVerifier.""" def __init__(self, token_verifier: TokenVerifier): self.token_verifier = token_verifier @@ -50,8 +48,7 @@ async def authenticate(self, conn: HTTPConnection): class RequireAuthMiddleware: - """ - Middleware that requires a valid Bearer token in the Authorization header. + """Middleware that requires a valid Bearer token in the Authorization header. This will validate the token with the auth provider and store the resulting auth info in the request state. @@ -63,8 +60,7 @@ def __init__( required_scopes: list[str], resource_metadata_url: AnyHttpUrl | None = None, ): - """ - Initialize the middleware. + """Initialize the middleware. Args: app: ASGI application diff --git a/src/mcp/server/auth/middleware/client_auth.py b/src/mcp/server/auth/middleware/client_auth.py index 6126c6e4f..2832f8352 100644 --- a/src/mcp/server/auth/middleware/client_auth.py +++ b/src/mcp/server/auth/middleware/client_auth.py @@ -13,23 +13,23 @@ class AuthenticationError(Exception): def __init__(self, message: str): - self.message = message # pragma: no cover + self.message = message class ClientAuthenticator: - """ - ClientAuthenticator is a callable which validates requests from a client + """ClientAuthenticator is a callable which validates requests from a client application, used to verify /token calls. + If, during registration, the client requested to be issued a secret, the authenticator asserts that /token calls must be authenticated with - that same token. + that same secret. + NOTE: clients can opt for no authentication during registration, in which case this logic is skipped. """ def __init__(self, provider: OAuthAuthorizationServerProvider[Any, Any, Any]): - """ - Initialize the dependency. + """Initialize the authenticator. Args: provider: Provider to look up client information @@ -37,8 +37,7 @@ def __init__(self, provider: OAuthAuthorizationServerProvider[Any, Any, Any]): self.provider = provider async def authenticate_request(self, request: Request) -> OAuthClientInformationFull: - """ - Authenticate a client from an HTTP request. + """Authenticate a client from an HTTP request. Extracts client credentials from the appropriate location based on the client's registered authentication method and validates them. @@ -86,7 +85,7 @@ async def authenticate_request(self, request: Request) -> OAuthClientInformation elif client.token_endpoint_auth_method == "client_secret_post": raw_form_data = form_data.get("client_secret") - # form_data.get() can return a UploadFile or None, so we need to check if it's a string + # form_data.get() can return an UploadFile or None, so we need to check if it's a string if isinstance(raw_form_data, str): request_client_secret = str(raw_form_data) @@ -99,15 +98,15 @@ async def authenticate_request(self, request: Request) -> OAuthClientInformation # If client from the store expects a secret, validate that the request provides # that secret - if client.client_secret: # pragma: no branch + if client.client_secret: if not request_client_secret: - raise AuthenticationError("Client secret is required") # pragma: no cover + raise AuthenticationError("Client secret is required") # hmac.compare_digest requires that both arguments are either bytes or a `str` containing # only ASCII characters. Since we do not control `request_client_secret`, we encode both # arguments to bytes. if not hmac.compare_digest(client.client_secret.encode(), request_client_secret.encode()): - raise AuthenticationError("Invalid client_secret") # pragma: no cover + raise AuthenticationError("Invalid client_secret") if client.client_secret_expires_at and client.client_secret_expires_at < int(time.time()): raise AuthenticationError("Client secret has expired") # pragma: no cover diff --git a/src/mcp/server/auth/provider.py b/src/mcp/server/auth/provider.py index 96296c148..957082a85 100644 --- a/src/mcp/server/auth/provider.py +++ b/src/mcp/server/auth/provider.py @@ -96,7 +96,7 @@ async def verify_token(self, token: str) -> AccessToken | None: """Verify a bearer token and return access info if valid.""" -# NOTE: FastMCP doesn't render any of these types in the user response, so it's +# NOTE: MCPServer doesn't render any of these types in the user response, so it's # OK to add fields to subclasses which should not be exposed externally. AuthorizationCodeT = TypeVar("AuthorizationCodeT", bound=AuthorizationCode) RefreshTokenT = TypeVar("RefreshTokenT", bound=RefreshToken) @@ -105,8 +105,7 @@ async def verify_token(self, token: str) -> AccessToken | None: class OAuthAuthorizationServerProvider(Protocol, Generic[AuthorizationCodeT, RefreshTokenT, AccessTokenT]): async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: - """ - Retrieves client information by client ID. + """Retrieves client information by client ID. Implementors MAY raise NotImplementedError if dynamic client registration is disabled in ClientRegistrationOptions. @@ -119,8 +118,7 @@ async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: """ async def register_client(self, client_info: OAuthClientInformationFull) -> None: - """ - Saves client information as part of registering it. + """Saves client information as part of registering it. Implementors MAY raise NotImplementedError if dynamic client registration is disabled in ClientRegistrationOptions. @@ -133,9 +131,9 @@ async def register_client(self, client_info: OAuthClientInformationFull) -> None """ async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: - """ - Called as part of the /authorize endpoint, and returns a URL that the client + """Handle the /authorize endpoint and return a URL that the client will be redirected to. + Many MCP implementations will redirect to a third-party provider to perform a second OAuth exchange with that provider. In this sort of setup, the client has an OAuth connection with the MCP server, and the MCP server has an OAuth @@ -154,7 +152,7 @@ async def authorize(self, client: OAuthClientInformationFull, params: Authorizat | | +------------+ - Implementations will need to define another handler on the MCP server return + Implementations will need to define another handler on the MCP server's return flow to perform the second redirect, and generate and store an authorization code as part of completing the OAuth authorization step. @@ -178,23 +176,21 @@ async def authorize(self, client: OAuthClientInformationFull, params: Authorizat async def load_authorization_code( self, client: OAuthClientInformationFull, authorization_code: str ) -> AuthorizationCodeT | None: - """ - Loads an AuthorizationCode by its code. + """Loads an AuthorizationCode by its code. Args: client: The client that requested the authorization code. authorization_code: The authorization code to get the challenge for. Returns: - The AuthorizationCode, or None if not found + The AuthorizationCode, or None if not found. """ ... async def exchange_authorization_code( self, client: OAuthClientInformationFull, authorization_code: AuthorizationCodeT ) -> OAuthToken: - """ - Exchanges an authorization code for an access token and refresh token. + """Exchanges an authorization code for an access token and refresh token. Args: client: The client exchanging the authorization code. @@ -204,13 +200,12 @@ async def exchange_authorization_code( The OAuth token, containing access and refresh tokens. Raises: - TokenError: If the request is invalid + TokenError: If the request is invalid. """ ... async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshTokenT | None: - """ - Loads a RefreshToken by its token string. + """Loads a RefreshToken by its token string. Args: client: The client that is requesting to load the refresh token. @@ -227,8 +222,7 @@ async def exchange_refresh_token( refresh_token: RefreshTokenT, scopes: list[str], ) -> OAuthToken: - """ - Exchanges a refresh token for an access token and refresh token. + """Exchanges a refresh token for an access token and refresh token. Implementations SHOULD rotate both the access token and refresh token. @@ -241,27 +235,25 @@ async def exchange_refresh_token( The OAuth token, containing access and refresh tokens. Raises: - TokenError: If the request is invalid + TokenError: If the request is invalid. """ ... async def load_access_token(self, token: str) -> AccessTokenT | None: - """ - Loads an access token by its token. + """Loads an access token by its token string. Args: token: The access token to verify. Returns: - The AuthInfo, or None if the token is invalid. + The access token, or None if the token is invalid. """ async def revoke_token( self, token: AccessTokenT | RefreshTokenT, ) -> None: - """ - Revokes an access or refresh token. + """Revokes an access or refresh token. If the given token is invalid or already revoked, this method should do nothing. @@ -270,7 +262,7 @@ async def revoke_token( provided. Args: - token: the token to revoke + token: The token to revoke. """ diff --git a/src/mcp/server/auth/routes.py b/src/mcp/server/auth/routes.py index 71a9c8b16..a72e81947 100644 --- a/src/mcp/server/auth/routes.py +++ b/src/mcp/server/auth/routes.py @@ -10,7 +10,7 @@ from starlette.types import ASGIApp from mcp.server.auth.handlers.authorize import AuthorizationHandler -from mcp.server.auth.handlers.metadata import MetadataHandler +from mcp.server.auth.handlers.metadata import MetadataHandler, ProtectedResourceMetadataHandler from mcp.server.auth.handlers.register import RegistrationHandler from mcp.server.auth.handlers.revoke import RevocationHandler from mcp.server.auth.handlers.token import TokenHandler @@ -18,33 +18,28 @@ from mcp.server.auth.provider import OAuthAuthorizationServerProvider from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions from mcp.server.streamable_http import MCP_PROTOCOL_VERSION_HEADER -from mcp.shared.auth import OAuthMetadata +from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata def validate_issuer_url(url: AnyHttpUrl): - """ - Validate that the issuer URL meets OAuth 2.0 requirements. + """Validate that the issuer URL meets OAuth 2.0 requirements. Args: - url: The issuer URL to validate + url: The issuer URL to validate. Raises: - ValueError: If the issuer URL is invalid + ValueError: If the issuer URL is invalid. """ - # RFC 8414 requires HTTPS, but we allow localhost HTTP for testing - if ( - url.scheme != "https" - and url.host != "localhost" - and (url.host is not None and not url.host.startswith("127.0.0.1")) - ): - raise ValueError("Issuer URL must be HTTPS") # pragma: no cover + # RFC 8414 requires HTTPS, but we allow loopback/localhost HTTP for testing + if url.scheme != "https" and url.host not in ("localhost", "127.0.0.1", "[::1]"): + raise ValueError("Issuer URL must be HTTPS") # No fragments or query parameters allowed if url.fragment: - raise ValueError("Issuer URL must not have a fragment") # pragma: no cover + raise ValueError("Issuer URL must not have a fragment") if url.query: - raise ValueError("Issuer URL must not have a query string") # pragma: no cover + raise ValueError("Issuer URL must not have a query string") AUTHORIZATION_PATH = "/authorize" @@ -188,8 +183,7 @@ def build_metadata( def build_resource_metadata_url(resource_server_url: AnyHttpUrl) -> AnyHttpUrl: - """ - Build RFC 9728 compliant protected resource metadata URL. + """Build RFC 9728 compliant protected resource metadata URL. Inserts /.well-known/oauth-protected-resource between host and resource path as specified in RFC 9728 §3.1. @@ -213,20 +207,18 @@ def create_protected_resource_routes( resource_name: str | None = None, resource_documentation: AnyHttpUrl | None = None, ) -> list[Route]: - """ - Create routes for OAuth 2.0 Protected Resource Metadata (RFC 9728). + """Create routes for OAuth 2.0 Protected Resource Metadata (RFC 9728). Args: resource_url: The URL of this resource server authorization_servers: List of authorization servers that can issue tokens scopes_supported: Optional list of scopes supported by this resource + resource_name: Optional human-readable name for this resource + resource_documentation: Optional URL to documentation for this resource Returns: List of Starlette routes for protected resource metadata """ - from mcp.server.auth.handlers.metadata import ProtectedResourceMetadataHandler - from mcp.shared.auth import ProtectedResourceMetadata - metadata = ProtectedResourceMetadata( resource=resource_url, authorization_servers=authorization_servers, diff --git a/src/mcp/server/context.py b/src/mcp/server/context.py new file mode 100644 index 000000000..d8e11d78b --- /dev/null +++ b/src/mcp/server/context.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Generic + +from typing_extensions import TypeVar + +from mcp.server.experimental.request_context import Experimental +from mcp.server.session import ServerSession +from mcp.shared._context import RequestContext +from mcp.shared.message import CloseSSEStreamCallback + +LifespanContextT = TypeVar("LifespanContextT", default=dict[str, Any]) +RequestT = TypeVar("RequestT", default=Any) + + +@dataclass(kw_only=True) +class ServerRequestContext(RequestContext[ServerSession], Generic[LifespanContextT, RequestT]): + lifespan_context: LifespanContextT + experimental: Experimental + request: RequestT | None = None + close_sse_stream: CloseSSEStreamCallback | None = None + close_standalone_sse_stream: CloseSSEStreamCallback | None = None diff --git a/src/mcp/server/elicitation.py b/src/mcp/server/elicitation.py index 49195415b..731c914ed 100644 --- a/src/mcp/server/elicitation.py +++ b/src/mcp/server/elicitation.py @@ -112,8 +112,8 @@ async def elicit_with_validation( This method can be used to interactively ask for additional information from the client within a tool's execution. The client might display the message to the - user and collect a response according to the provided schema. Or in case a - client is an agent, it might decide how to handle the elicitation -- either by asking + user and collect a response according to the provided schema. If the client + is an agent, it might decide how to handle the elicitation -- either by asking the user or automatically generating a response. For sensitive data like credentials or OAuth flows, use elicit_url() instead. @@ -125,7 +125,7 @@ async def elicit_with_validation( result = await session.elicit_form( message=message, - requestedSchema=json_schema, + requested_schema=json_schema, related_request_id=related_request_id, ) diff --git a/src/mcp/server/experimental/__init__.py b/src/mcp/server/experimental/__init__.py index 824bb8b8b..fd1db623f 100644 --- a/src/mcp/server/experimental/__init__.py +++ b/src/mcp/server/experimental/__init__.py @@ -1,5 +1,4 @@ -""" -Server-side experimental features. +"""Server-side experimental features. WARNING: These APIs are experimental and may change without notice. diff --git a/src/mcp/server/experimental/request_context.py b/src/mcp/server/experimental/request_context.py index 78e75beb6..3eba65822 100644 --- a/src/mcp/server/experimental/request_context.py +++ b/src/mcp/server/experimental/request_context.py @@ -1,5 +1,4 @@ -""" -Experimental request context features. +"""Experimental request context features. This module provides the Experimental class which gives access to experimental features within a request context, such as task-augmented request handling. @@ -14,7 +13,7 @@ from mcp.server.experimental.task_context import ServerTaskContext from mcp.server.experimental.task_support import TaskSupport from mcp.server.session import ServerSession -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.shared.experimental.tasks.helpers import MODEL_IMMEDIATE_RESPONSE_KEY, is_terminal from mcp.types import ( METHOD_NOT_FOUND, @@ -32,8 +31,7 @@ @dataclass class Experimental: - """ - Experimental features context for task-augmented requests. + """Experimental features context for task-augmented requests. Provides helpers for validating task execution compatibility and running tasks with automatic lifecycle management. @@ -59,29 +57,25 @@ def client_supports_tasks(self) -> bool: return self._client_capabilities.tasks is not None def validate_task_mode( - self, - tool_task_mode: TaskExecutionMode | None, - *, - raise_error: bool = True, + self, tool_task_mode: TaskExecutionMode | None, *, raise_error: bool = True ) -> ErrorData | None: - """ - Validate that the request is compatible with the tool's task execution mode. + """Validate that the request is compatible with the tool's task execution mode. Per MCP spec: - - "required": Clients MUST invoke as task. Server returns -32601 if not. - - "forbidden" (or None): Clients MUST NOT invoke as task. Server returns -32601 if they do. + - "required": Clients MUST invoke as a task. Server returns -32601 if not. + - "forbidden" (or None): Clients MUST NOT invoke as a task. Server returns -32601 if they do. - "optional": Either is acceptable. Args: tool_task_mode: The tool's execution.taskSupport value ("forbidden", "optional", "required", or None) - raise_error: If True, raises McpError on validation failure. If False, returns ErrorData. + raise_error: If True, raises MCPError on validation failure. If False, returns ErrorData. Returns: None if valid, ErrorData if invalid and raise_error=False Raises: - McpError: If invalid and raise_error=True + MCPError: If invalid and raise_error=True """ mode = tool_task_mode or TASK_FORBIDDEN @@ -89,48 +83,35 @@ def validate_task_mode( error: ErrorData | None = None if mode == TASK_REQUIRED and not self.is_task: - error = ErrorData( - code=METHOD_NOT_FOUND, - message="This tool requires task-augmented invocation", - ) + error = ErrorData(code=METHOD_NOT_FOUND, message="This tool requires task-augmented invocation") elif mode == TASK_FORBIDDEN and self.is_task: - error = ErrorData( - code=METHOD_NOT_FOUND, - message="This tool does not support task-augmented invocation", - ) + error = ErrorData(code=METHOD_NOT_FOUND, message="This tool does not support task-augmented invocation") if error is not None and raise_error: - raise McpError(error) + raise MCPError.from_error_data(error) return error - def validate_for_tool( - self, - tool: Tool, - *, - raise_error: bool = True, - ) -> ErrorData | None: - """ - Validate that the request is compatible with the given tool. + def validate_for_tool(self, tool: Tool, *, raise_error: bool = True) -> ErrorData | None: + """Validate that the request is compatible with the given tool. Convenience wrapper around validate_task_mode that extracts the mode from a Tool. Args: tool: The Tool definition - raise_error: If True, raises McpError on validation failure. + raise_error: If True, raises MCPError on validation failure. Returns: None if valid, ErrorData if invalid and raise_error=False """ - mode = tool.execution.taskSupport if tool.execution else None + mode = tool.execution.task_support if tool.execution else None return self.validate_task_mode(mode, raise_error=raise_error) def can_use_tool(self, tool_task_mode: TaskExecutionMode | None) -> bool: - """ - Check if this client can use a tool with the given task mode. + """Check if this client can use a tool with the given task mode. Useful for filtering tool lists or providing warnings. - Returns False if tool requires "required" but client doesn't support tasks. + Returns False if the tool's task mode is "required" but the client doesn't support tasks. Args: tool_task_mode: The tool's execution.taskSupport value @@ -150,8 +131,7 @@ async def run_task( task_id: str | None = None, model_immediate_response: str | None = None, ) -> CreateTaskResult: - """ - Create a task, spawn background work, and return CreateTaskResult immediately. + """Create a task, spawn background work, and return CreateTaskResult immediately. This is the recommended way to handle task-augmented tool calls. It: 1. Creates a task in the store @@ -180,19 +160,18 @@ async def run_task( RuntimeError: If task support is not enabled or task_metadata is missing Example: - @server.call_tool() - async def handle_tool(name: str, args: dict): - ctx = server.request_context - + ```python + async def handle_tool(ctx: RequestContext, params: CallToolRequestParams) -> CallToolResult: async def work(task: ServerTaskContext) -> CallToolResult: result = await task.elicit( message="Are you sure?", - requestedSchema={"type": "object", ...} + requested_schema={"type": "object", ...} ) confirmed = result.content.get("confirm", False) return CallToolResult(content=[TextContent(text="Done" if confirmed else "Cancelled")]) return await ctx.experimental.run_task(work) + ``` WARNING: This API is experimental and may change without notice. """ diff --git a/src/mcp/server/experimental/session_features.py b/src/mcp/server/experimental/session_features.py index 4842da517..2f9d1b032 100644 --- a/src/mcp/server/experimental/session_features.py +++ b/src/mcp/server/experimental/session_features.py @@ -1,5 +1,4 @@ -""" -Experimental server session features for server→client task operations. +"""Experimental server session features for server→client task operations. This module provides the server-side equivalent of ExperimentalClientFeatures, allowing the server to send task-augmented requests to the client and poll for results. @@ -10,7 +9,7 @@ from collections.abc import AsyncIterator from typing import TYPE_CHECKING, Any, TypeVar -import mcp.types as types +from mcp import types from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages from mcp.shared.experimental.tasks.capabilities import ( require_task_augmented_elicitation, @@ -25,8 +24,7 @@ class ExperimentalServerSessionFeatures: - """ - Experimental server session features for server→client task operations. + """Experimental server session features for server→client task operations. This provides the server-side equivalent of ExperimentalClientFeatures, allowing the server to send task-augmented requests to the client and @@ -42,8 +40,7 @@ def __init__(self, session: "ServerSession") -> None: self._session = session async def get_task(self, task_id: str) -> types.GetTaskResult: - """ - Send tasks/get to the client to get task status. + """Send tasks/get to the client to get task status. Args: task_id: The task identifier @@ -52,7 +49,7 @@ async def get_task(self, task_id: str) -> types.GetTaskResult: GetTaskResult containing the task status """ return await self._session.send_request( - types.ServerRequest(types.GetTaskRequest(params=types.GetTaskRequestParams(taskId=task_id))), + types.GetTaskRequest(params=types.GetTaskRequestParams(task_id=task_id)), types.GetTaskResult, ) @@ -61,8 +58,7 @@ async def get_task_result( task_id: str, result_type: type[ResultT], ) -> ResultT: - """ - Send tasks/result to the client to retrieve the final result. + """Send tasks/result to the client to retrieve the final result. Args: task_id: The task identifier @@ -72,13 +68,12 @@ async def get_task_result( The task result, validated against result_type """ return await self._session.send_request( - types.ServerRequest(types.GetTaskPayloadRequest(params=types.GetTaskPayloadRequestParams(taskId=task_id))), + types.GetTaskPayloadRequest(params=types.GetTaskPayloadRequestParams(task_id=task_id)), result_type, ) async def poll_task(self, task_id: str) -> AsyncIterator[types.GetTaskResult]: - """ - Poll a client task until it reaches terminal status. + """Poll a client task until it reaches terminal status. Yields GetTaskResult for each poll, allowing the caller to react to status changes. Exits when task reaches a terminal status. @@ -97,12 +92,11 @@ async def poll_task(self, task_id: str) -> AsyncIterator[types.GetTaskResult]: async def elicit_as_task( self, message: str, - requestedSchema: types.ElicitRequestedSchema, + requested_schema: types.ElicitRequestedSchema, *, ttl: int = 60000, ) -> types.ElicitResult: - """ - Send a task-augmented elicitation to the client and poll until complete. + """Send a task-augmented elicitation to the client and poll until complete. The client will create a local task, process the elicitation asynchronously, and return the result when ready. This method handles the full flow: @@ -113,32 +107,30 @@ async def elicit_as_task( Args: message: The message to present to the user - requestedSchema: Schema defining the expected response + requested_schema: Schema defining the expected response ttl: Task time-to-live in milliseconds Returns: The client's elicitation response Raises: - McpError: If client doesn't support task-augmented elicitation + MCPError: If client doesn't support task-augmented elicitation """ client_caps = self._session.client_params.capabilities if self._session.client_params else None require_task_augmented_elicitation(client_caps) create_result = await self._session.send_request( - types.ServerRequest( - types.ElicitRequest( - params=types.ElicitRequestFormParams( - message=message, - requestedSchema=requestedSchema, - task=types.TaskMetadata(ttl=ttl), - ) + types.ElicitRequest( + params=types.ElicitRequestFormParams( + message=message, + requested_schema=requested_schema, + task=types.TaskMetadata(ttl=ttl), ) ), types.CreateTaskResult, ) - task_id = create_result.task.taskId + task_id = create_result.task.task_id async for _ in self.poll_task(task_id): pass @@ -160,8 +152,7 @@ async def create_message_as_task( tools: list[types.Tool] | None = None, tool_choice: types.ToolChoice | None = None, ) -> types.CreateMessageResult: - """ - Send a task-augmented sampling request and poll until complete. + """Send a task-augmented sampling request and poll until complete. The client will create a local task, process the sampling request asynchronously, and return the result when ready. @@ -183,7 +174,7 @@ async def create_message_as_task( The sampling result from the client Raises: - McpError: If client doesn't support task-augmented sampling or tools + MCPError: If client doesn't support task-augmented sampling or tools ValueError: If tool_use or tool_result message structure is invalid """ client_caps = self._session.client_params.capabilities if self._session.client_params else None @@ -192,27 +183,25 @@ async def create_message_as_task( validate_tool_use_result_messages(messages) create_result = await self._session.send_request( - types.ServerRequest( - types.CreateMessageRequest( - params=types.CreateMessageRequestParams( - messages=messages, - maxTokens=max_tokens, - systemPrompt=system_prompt, - includeContext=include_context, - temperature=temperature, - stopSequences=stop_sequences, - metadata=metadata, - modelPreferences=model_preferences, - tools=tools, - toolChoice=tool_choice, - task=types.TaskMetadata(ttl=ttl), - ) + types.CreateMessageRequest( + params=types.CreateMessageRequestParams( + messages=messages, + max_tokens=max_tokens, + system_prompt=system_prompt, + include_context=include_context, + temperature=temperature, + stop_sequences=stop_sequences, + metadata=metadata, + model_preferences=model_preferences, + tools=tools, + tool_choice=tool_choice, + task=types.TaskMetadata(ttl=ttl), ) ), types.CreateTaskResult, ) - task_id = create_result.task.taskId + task_id = create_result.task.task_id async for _ in self.poll_task(task_id): pass diff --git a/src/mcp/server/experimental/task_context.py b/src/mcp/server/experimental/task_context.py index e6e14fc93..1fc45badf 100644 --- a/src/mcp/server/experimental/task_context.py +++ b/src/mcp/server/experimental/task_context.py @@ -1,5 +1,4 @@ -""" -ServerTaskContext - Server-integrated task context with elicitation and sampling. +"""ServerTaskContext - Server-integrated task context with elicitation and sampling. This wraps the pure TaskContext and adds server-specific functionality: - Elicitation (task.elicit()) @@ -14,7 +13,7 @@ from mcp.server.experimental.task_result_handler import TaskResultHandler from mcp.server.session import ServerSession from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.shared.experimental.tasks.capabilities import ( require_task_augmented_elicitation, require_task_augmented_sampling, @@ -33,14 +32,12 @@ ElicitationCapability, ElicitRequestedSchema, ElicitResult, - ErrorData, IncludeContext, ModelPreferences, RequestId, Result, SamplingCapability, SamplingMessage, - ServerNotification, Task, TaskMetadata, TaskStatusNotification, @@ -51,8 +48,7 @@ class ServerTaskContext: - """ - Server-integrated task context with elicitation and sampling. + """Server-integrated task context with elicitation and sampling. This wraps a pure TaskContext and adds server-specific functionality: - elicit() for sending elicitation requests to the client @@ -60,18 +56,20 @@ class ServerTaskContext: - Status notifications via the session Example: + ```python async def my_task_work(task: ServerTaskContext) -> CallToolResult: await task.update_status("Starting...") result = await task.elicit( message="Continue?", - requestedSchema={"type": "object", "properties": {"ok": {"type": "boolean"}}} + requested_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}} ) if result.content.get("ok"): return CallToolResult(content=[TextContent(text="Done!")]) else: return CallToolResult(content=[TextContent(text="Cancelled")]) + ``` """ def __init__( @@ -83,8 +81,7 @@ def __init__( queue: TaskMessageQueue, handler: TaskResultHandler | None = None, ): - """ - Create a ServerTaskContext. + """Create a ServerTaskContext. Args: task: The Task object @@ -123,8 +120,7 @@ def request_cancellation(self) -> None: # Enhanced methods with notifications async def update_status(self, message: str, *, notify: bool = True) -> None: - """ - Update the task's status message. + """Update the task's status message. Args: message: The new status message @@ -135,8 +131,7 @@ async def update_status(self, message: str, *, notify: bool = True) -> None: await self._send_notification() async def complete(self, result: Result, *, notify: bool = True) -> None: - """ - Mark the task as completed with the given result. + """Mark the task as completed with the given result. Args: result: The task result @@ -147,8 +142,7 @@ async def complete(self, result: Result, *, notify: bool = True) -> None: await self._send_notification() async def fail(self, error: str, *, notify: bool = True) -> None: - """ - Mark the task as failed with an error message. + """Mark the task as failed with an error message. Args: error: The error message @@ -162,17 +156,15 @@ async def _send_notification(self) -> None: """Send a task status notification to the client.""" task = self._ctx.task await self._session.send_notification( - ServerNotification( - TaskStatusNotification( - params=TaskStatusNotificationParams( - taskId=task.taskId, - status=task.status, - statusMessage=task.statusMessage, - createdAt=task.createdAt, - lastUpdatedAt=task.lastUpdatedAt, - ttl=task.ttl, - pollInterval=task.pollInterval, - ) + TaskStatusNotification( + params=TaskStatusNotificationParams( + task_id=task.task_id, + status=task.status, + status_message=task.status_message, + created_at=task.created_at, + last_updated_at=task.last_updated_at, + ttl=task.ttl, + poll_interval=task.poll_interval, ) ) ) @@ -182,30 +174,19 @@ async def _send_notification(self) -> None: def _check_elicitation_capability(self) -> None: """Check if the client supports elicitation.""" if not self._session.check_client_capability(ClientCapabilities(elicitation=ElicitationCapability())): - raise McpError( - ErrorData( - code=INVALID_REQUEST, - message="Client does not support elicitation capability", - ) - ) + raise MCPError(code=INVALID_REQUEST, message="Client does not support elicitation capability") def _check_sampling_capability(self) -> None: """Check if the client supports sampling.""" if not self._session.check_client_capability(ClientCapabilities(sampling=SamplingCapability())): - raise McpError( - ErrorData( - code=INVALID_REQUEST, - message="Client does not support sampling capability", - ) - ) + raise MCPError(code=INVALID_REQUEST, message="Client does not support sampling capability") async def elicit( self, message: str, - requestedSchema: ElicitRequestedSchema, + requested_schema: ElicitRequestedSchema, ) -> ElicitResult: - """ - Send an elicitation request via the task message queue. + """Send an elicitation request via the task message queue. This method: 1. Checks client capability @@ -217,13 +198,13 @@ async def elicit( Args: message: The message to present to the user - requestedSchema: Schema defining the expected response structure + requested_schema: Schema defining the expected response structure Returns: The client's response Raises: - McpError: If client doesn't support elicitation capability + MCPError: If client doesn't support elicitation capability """ self._check_elicitation_capability() @@ -236,7 +217,7 @@ async def elicit( # Build the request using session's helper request = self._session._build_elicit_form_request( # pyright: ignore[reportPrivateUsage] message=message, - requestedSchema=requestedSchema, + requested_schema=requested_schema, related_task_id=self.task_id, ) request_id: RequestId = request.id @@ -257,8 +238,7 @@ async def elicit( response_data = await resolver.wait() await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING) return ElicitResult.model_validate(response_data) - except anyio.get_cancelled_exc_class(): # pragma: no cover - # Coverage can't track async exception handlers reliably. + except anyio.get_cancelled_exc_class(): # This path is tested in test_elicit_restores_status_on_cancellation # which verifies status is restored to "working" after cancellation. await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING) @@ -270,8 +250,7 @@ async def elicit_url( url: str, elicitation_id: str, ) -> ElicitResult: - """ - Send a URL mode elicitation request via the task message queue. + """Send a URL mode elicitation request via the task message queue. This directs the user to an external URL for out-of-band interactions like OAuth flows, credential collection, or payment processing. @@ -293,7 +272,7 @@ async def elicit_url( The client's response indicating acceptance, decline, or cancellation Raises: - McpError: If client doesn't support elicitation capability + MCPError: If client doesn't support elicitation capability RuntimeError: If handler is not configured """ self._check_elicitation_capability() @@ -347,8 +326,7 @@ async def create_message( tools: list[Tool] | None = None, tool_choice: ToolChoice | None = None, ) -> CreateMessageResult: - """ - Send a sampling request via the task message queue. + """Send a sampling request via the task message queue. This method: 1. Checks client capability @@ -374,7 +352,7 @@ async def create_message( The sampling result from the client Raises: - McpError: If client doesn't support sampling capability or tools + MCPError: If client doesn't support sampling capability or tools ValueError: If tool_use or tool_result message structure is invalid """ self._check_sampling_capability() @@ -420,8 +398,7 @@ async def create_message( response_data = await resolver.wait() await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING) return CreateMessageResult.model_validate(response_data) - except anyio.get_cancelled_exc_class(): # pragma: no cover - # Coverage can't track async exception handlers reliably. + except anyio.get_cancelled_exc_class(): # This path is tested in test_create_message_restores_status_on_cancellation # which verifies status is restored to "working" after cancellation. await self._store.update_task(self.task_id, status=TASK_STATUS_WORKING) @@ -430,12 +407,11 @@ async def create_message( async def elicit_as_task( self, message: str, - requestedSchema: ElicitRequestedSchema, + requested_schema: ElicitRequestedSchema, *, ttl: int = 60000, ) -> ElicitResult: - """ - Send a task-augmented elicitation via the queue, then poll client. + """Send a task-augmented elicitation via the queue, then poll client. This is for use inside a task-augmented tool call when you want the client to handle the elicitation as its own task. The elicitation request is queued @@ -444,14 +420,14 @@ async def elicit_as_task( Args: message: The message to present to the user - requestedSchema: Schema defining the expected response structure + requested_schema: Schema defining the expected response structure ttl: Task time-to-live in milliseconds for the client's task Returns: The client's elicitation response Raises: - McpError: If client doesn't support task-augmented elicitation + MCPError: If client doesn't support task-augmented elicitation RuntimeError: If handler is not configured """ client_caps = self._session.client_params.capabilities if self._session.client_params else None @@ -465,7 +441,7 @@ async def elicit_as_task( request = self._session._build_elicit_form_request( # pyright: ignore[reportPrivateUsage] message=message, - requestedSchema=requestedSchema, + requested_schema=requested_schema, related_task_id=self.task_id, task=TaskMetadata(ttl=ttl), ) @@ -486,7 +462,7 @@ async def elicit_as_task( # Wait for initial response (CreateTaskResult from client) response_data = await resolver.wait() create_result = CreateTaskResult.model_validate(response_data) - client_task_id = create_result.task.taskId + client_task_id = create_result.task.task_id # Poll the client's task using session.experimental async for _ in self._session.experimental.poll_task(client_task_id): @@ -520,8 +496,7 @@ async def create_message_as_task( tools: list[Tool] | None = None, tool_choice: ToolChoice | None = None, ) -> CreateMessageResult: - """ - Send a task-augmented sampling request via the queue, then poll client. + """Send a task-augmented sampling request via the queue, then poll client. This is for use inside a task-augmented tool call when you want the client to handle the sampling as its own task. The request is queued and delivered @@ -545,7 +520,7 @@ async def create_message_as_task( The sampling result from the client Raises: - McpError: If client doesn't support task-augmented sampling or tools + MCPError: If client doesn't support task-augmented sampling or tools ValueError: If tool_use or tool_result message structure is invalid RuntimeError: If handler is not configured """ @@ -592,7 +567,7 @@ async def create_message_as_task( # Wait for initial response (CreateTaskResult from client) response_data = await resolver.wait() create_result = CreateTaskResult.model_validate(response_data) - client_task_id = create_result.task.taskId + client_task_id = create_result.task.task_id # Poll the client's task using session.experimental async for _ in self._session.experimental.poll_task(client_task_id): diff --git a/src/mcp/server/experimental/task_result_handler.py b/src/mcp/server/experimental/task_result_handler.py index 0b869216e..b2268bc1c 100644 --- a/src/mcp/server/experimental/task_result_handler.py +++ b/src/mcp/server/experimental/task_result_handler.py @@ -1,5 +1,4 @@ -""" -TaskResultHandler - Integrated handler for tasks/result endpoint. +"""TaskResultHandler - Integrated handler for tasks/result endpoint. This implements the dequeue-send-wait pattern from the MCP Tasks spec: 1. Dequeue all pending messages for the task @@ -16,7 +15,7 @@ import anyio from mcp.server.session import ServerSession -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.shared.experimental.tasks.helpers import RELATED_TASK_METADATA_KEY, is_terminal from mcp.shared.experimental.tasks.message_queue import TaskMessageQueue from mcp.shared.experimental.tasks.resolver import Resolver @@ -27,7 +26,6 @@ ErrorData, GetTaskPayloadRequest, GetTaskPayloadResult, - JSONRPCMessage, RelatedTaskMetadata, RequestId, ) @@ -36,8 +34,7 @@ class TaskResultHandler: - """ - Handler for tasks/result that implements the message queue pattern. + """Handler for tasks/result that implements the message queue pattern. This handler: 1. Dequeues pending messages (elicitations, notifications) for the task @@ -47,17 +44,14 @@ class TaskResultHandler: 5. Returns the final result Usage: - # Create handler with store and queue - handler = TaskResultHandler(task_store, message_queue) - - # Register it with the server - @server.experimental.get_task_result() - async def handle_task_result(req: GetTaskPayloadRequest) -> GetTaskPayloadResult: - ctx = server.request_context - return await handler.handle(req, ctx.session, ctx.request_id) - - # Or use the convenience method - handler.register(server) + async def handle_task_result( + ctx: ServerRequestContext, params: GetTaskPayloadRequestParams + ) -> GetTaskPayloadResult: + ... + + server.experimental.enable_tasks( + on_task_result=handle_task_result, + ) """ def __init__( @@ -75,8 +69,7 @@ async def send_message( session: ServerSession, message: SessionMessage, ) -> None: - """ - Send a message via the session. + """Send a message via the session. This is a helper for delivering queued task messages. """ @@ -88,8 +81,7 @@ async def handle( session: ServerSession, request_id: RequestId, ) -> GetTaskPayloadResult: - """ - Handle a tasks/result request. + """Handle a tasks/result request. This implements the dequeue-send-wait loop: 1. Dequeue all pending messages @@ -106,17 +98,12 @@ async def handle( Returns: GetTaskPayloadResult with the task's final payload """ - task_id = request.params.taskId + task_id = request.params.task_id while True: task = await self._store.get_task(task_id) if task is None: - raise McpError( - ErrorData( - code=INVALID_PARAMS, - message=f"Task not found: {task_id}", - ) - ) + raise MCPError(code=INVALID_PARAMS, message=f"Task not found: {task_id}") await self._deliver_queued_messages(task_id, session, request_id) @@ -126,7 +113,7 @@ async def handle( # GetTaskPayloadResult is a Result with extra="allow" # The stored result contains the actual payload data # Per spec: tasks/result MUST include _meta with related-task metadata - related_task = RelatedTaskMetadata(taskId=task_id) + related_task = RelatedTaskMetadata(task_id=task_id) related_task_meta: dict[str, Any] = {RELATED_TASK_METADATA_KEY: related_task.model_dump(by_alias=True)} if result is not None: result_data = result.model_dump(by_alias=True) @@ -144,8 +131,7 @@ async def _deliver_queued_messages( session: ServerSession, request_id: RequestId, ) -> None: - """ - Dequeue and send all pending messages for a task. + """Dequeue and send all pending messages for a task. Each message is sent via the session's write stream with relatedRequestId set so responses route back to this stream. @@ -166,14 +152,13 @@ async def _deliver_queued_messages( # Send the message with relatedRequestId for routing session_message = SessionMessage( - message=JSONRPCMessage(message.message), + message=message.message, metadata=ServerMessageMetadata(related_request_id=request_id), ) await self.send_message(session, session_message) async def _wait_for_task_update(self, task_id: str) -> None: - """ - Wait for task to be updated (status change or new message). + """Wait for task to be updated (status change or new message). Races between store update and queue message - first one wins. """ @@ -199,8 +184,7 @@ async def wait_for_queue() -> None: tg.start_soon(wait_for_queue) def route_response(self, request_id: RequestId, response: dict[str, Any]) -> bool: - """ - Route a response back to the waiting resolver. + """Route a response back to the waiting resolver. This is called when a response arrives for a queued request. @@ -218,8 +202,7 @@ def route_response(self, request_id: RequestId, response: dict[str, Any]) -> boo return False def route_error(self, request_id: RequestId, error: ErrorData) -> bool: - """ - Route an error back to the waiting resolver. + """Route an error back to the waiting resolver. Args: request_id: The request ID from the error response @@ -230,6 +213,6 @@ def route_error(self, request_id: RequestId, error: ErrorData) -> bool: """ resolver = self._pending_requests.pop(request_id, None) if resolver is not None and not resolver.done(): - resolver.set_exception(McpError(error)) + resolver.set_exception(MCPError.from_error_data(error)) return True return False diff --git a/src/mcp/server/experimental/task_support.py b/src/mcp/server/experimental/task_support.py index dbb2ed6d2..b54219504 100644 --- a/src/mcp/server/experimental/task_support.py +++ b/src/mcp/server/experimental/task_support.py @@ -1,5 +1,4 @@ -""" -TaskSupport - Configuration for experimental task support. +"""TaskSupport - Configuration for experimental task support. This module provides the TaskSupport class which encapsulates all the infrastructure needed for task-augmented requests: store, queue, and handler. @@ -21,8 +20,7 @@ @dataclass class TaskSupport: - """ - Configuration for experimental task support. + """Configuration for experimental task support. Encapsulates the task store, message queue, result handler, and task group for spawning background work. @@ -33,14 +31,20 @@ class TaskSupport: - Manages a task group for background task execution Example: - # Simple in-memory setup + Simple in-memory setup: + + ```python server.experimental.enable_tasks() + ``` + + Custom store/queue for distributed systems: - # Custom store/queue for distributed systems + ```python server.experimental.enable_tasks( store=RedisTaskStore(redis_url), queue=RedisTaskMessageQueue(redis_url), ) + ``` """ store: TaskStore @@ -65,8 +69,7 @@ def task_group(self) -> TaskGroup: @asynccontextmanager async def run(self) -> AsyncIterator[None]: - """ - Run the task support lifecycle. + """Run the task support lifecycle. This creates a task group for spawning background task work. Called automatically by Server.run(). @@ -84,8 +87,7 @@ async def run(self) -> AsyncIterator[None]: self._task_group = None def configure_session(self, session: ServerSession) -> None: - """ - Configure a session for task support. + """Configure a session for task support. This registers the result handler as a response router so that responses to queued requests (elicitation, sampling) are routed @@ -100,8 +102,7 @@ def configure_session(self, session: ServerSession) -> None: @classmethod def in_memory(cls) -> "TaskSupport": - """ - Create in-memory task support. + """Create in-memory task support. Suitable for development, testing, and single-process servers. For distributed systems, provide custom store and queue implementations. diff --git a/src/mcp/server/fastmcp/__init__.py b/src/mcp/server/fastmcp/__init__.py deleted file mode 100644 index a89902cfd..000000000 --- a/src/mcp/server/fastmcp/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""FastMCP - A more ergonomic interface for MCP servers.""" - -from importlib.metadata import version - -from mcp.types import Icon - -from .server import Context, FastMCP -from .utilities.types import Audio, Image - -__version__ = version("mcp") -__all__ = ["FastMCP", "Context", "Image", "Audio", "Icon"] diff --git a/src/mcp/server/fastmcp/exceptions.py b/src/mcp/server/fastmcp/exceptions.py deleted file mode 100644 index fb5bda106..000000000 --- a/src/mcp/server/fastmcp/exceptions.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Custom exceptions for FastMCP.""" - - -class FastMCPError(Exception): - """Base error for FastMCP.""" - - -class ValidationError(FastMCPError): - """Error in validating parameters or return values.""" - - -class ResourceError(FastMCPError): - """Error in resource operations.""" - - -class ToolError(FastMCPError): - """Error in tool operations.""" - - -class InvalidSignature(Exception): - """Invalid signature for use with FastMCP.""" diff --git a/src/mcp/server/fastmcp/utilities/__init__.py b/src/mcp/server/fastmcp/utilities/__init__.py deleted file mode 100644 index be448f97a..000000000 --- a/src/mcp/server/fastmcp/utilities/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""FastMCP utility modules.""" diff --git a/src/mcp/server/lowlevel/__init__.py b/src/mcp/server/lowlevel/__init__.py index 66df38991..37191ba1a 100644 --- a/src/mcp/server/lowlevel/__init__.py +++ b/src/mcp/server/lowlevel/__init__.py @@ -1,3 +1,3 @@ from .server import NotificationOptions, Server -__all__ = ["Server", "NotificationOptions"] +__all__ = ["NotificationOptions", "Server"] diff --git a/src/mcp/server/lowlevel/experimental.py b/src/mcp/server/lowlevel/experimental.py index 0e6655b3d..5a907b640 100644 --- a/src/mcp/server/lowlevel/experimental.py +++ b/src/mcp/server/lowlevel/experimental.py @@ -7,42 +7,43 @@ import logging from collections.abc import Awaitable, Callable -from typing import TYPE_CHECKING +from typing import Any, Generic +from typing_extensions import TypeVar + +from mcp.server.context import ServerRequestContext from mcp.server.experimental.task_support import TaskSupport -from mcp.server.lowlevel.func_inspection import create_call_wrapper -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.shared.experimental.tasks.helpers import cancel_task from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore from mcp.shared.experimental.tasks.message_queue import InMemoryTaskMessageQueue, TaskMessageQueue from mcp.shared.experimental.tasks.store import TaskStore from mcp.types import ( INVALID_PARAMS, - CancelTaskRequest, + CancelTaskRequestParams, CancelTaskResult, - ErrorData, GetTaskPayloadRequest, + GetTaskPayloadRequestParams, GetTaskPayloadResult, - GetTaskRequest, + GetTaskRequestParams, GetTaskResult, - ListTasksRequest, ListTasksResult, + PaginatedRequestParams, ServerCapabilities, - ServerResult, ServerTasksCapability, ServerTasksRequestsCapability, + TasksCallCapability, TasksCancelCapability, TasksListCapability, TasksToolsCapability, ) -if TYPE_CHECKING: - from mcp.server.lowlevel.server import Server - logger = logging.getLogger(__name__) +LifespanResultT = TypeVar("LifespanResultT", default=Any) + -class ExperimentalHandlers: +class ExperimentalHandlers(Generic[LifespanResultT]): """Experimental request/notification handlers. WARNING: These APIs are experimental and may change without notice. @@ -50,13 +51,13 @@ class ExperimentalHandlers: def __init__( self, - server: Server, - request_handlers: dict[type, Callable[..., Awaitable[ServerResult]]], - notification_handlers: dict[type, Callable[..., Awaitable[None]]], - ): - self._server = server - self._request_handlers = request_handlers - self._notification_handlers = notification_handlers + add_request_handler: Callable[ + [str, Callable[[ServerRequestContext[LifespanResultT], Any], Awaitable[Any]]], None + ], + has_handler: Callable[[str], bool], + ) -> None: + self._add_request_handler = add_request_handler + self._has_handler = has_handler self._task_support: TaskSupport | None = None @property @@ -66,49 +67,71 @@ def task_support(self) -> TaskSupport | None: def update_capabilities(self, capabilities: ServerCapabilities) -> None: # Only add tasks capability if handlers are registered - if not any( - req_type in self._request_handlers - for req_type in [GetTaskRequest, ListTasksRequest, CancelTaskRequest, GetTaskPayloadRequest] - ): + if not any(self._has_handler(method) for method in ["tasks/get", "tasks/list", "tasks/cancel", "tasks/result"]): return capabilities.tasks = ServerTasksCapability() - if ListTasksRequest in self._request_handlers: + if self._has_handler("tasks/list"): capabilities.tasks.list = TasksListCapability() - if CancelTaskRequest in self._request_handlers: + if self._has_handler("tasks/cancel"): capabilities.tasks.cancel = TasksCancelCapability() capabilities.tasks.requests = ServerTasksRequestsCapability( - tools=TasksToolsCapability() + tools=TasksToolsCapability(call=TasksCallCapability()) ) # assuming always supported for now def enable_tasks( self, store: TaskStore | None = None, queue: TaskMessageQueue | None = None, + *, + on_get_task: Callable[[ServerRequestContext[LifespanResultT], GetTaskRequestParams], Awaitable[GetTaskResult]] + | None = None, + on_task_result: Callable[ + [ServerRequestContext[LifespanResultT], GetTaskPayloadRequestParams], Awaitable[GetTaskPayloadResult] + ] + | None = None, + on_list_tasks: Callable[ + [ServerRequestContext[LifespanResultT], PaginatedRequestParams | None], Awaitable[ListTasksResult] + ] + | None = None, + on_cancel_task: Callable[ + [ServerRequestContext[LifespanResultT], CancelTaskRequestParams], Awaitable[CancelTaskResult] + ] + | None = None, ) -> TaskSupport: - """ - Enable experimental task support. + """Enable experimental task support. - This sets up the task infrastructure and auto-registers default handlers - for tasks/get, tasks/result, tasks/list, and tasks/cancel. + This sets up the task infrastructure and registers handlers for + tasks/get, tasks/result, tasks/list, and tasks/cancel. Custom handlers + can be provided via the on_* kwargs; any not provided will use defaults. Args: store: Custom TaskStore implementation (defaults to InMemoryTaskStore) queue: Custom TaskMessageQueue implementation (defaults to InMemoryTaskMessageQueue) + on_get_task: Custom handler for tasks/get + on_task_result: Custom handler for tasks/result + on_list_tasks: Custom handler for tasks/list + on_cancel_task: Custom handler for tasks/cancel Returns: The TaskSupport configuration object Example: - # Simple in-memory setup + Simple in-memory setup: + + ```python server.experimental.enable_tasks() + ``` + + Custom store/queue for distributed systems: - # Custom store/queue for distributed systems + ```python server.experimental.enable_tasks( store=RedisTaskStore(redis_url), queue=RedisTaskMessageQueue(redis_url), ) + ``` WARNING: This API is experimental and may change without notice. """ @@ -118,171 +141,70 @@ def enable_tasks( queue = InMemoryTaskMessageQueue() self._task_support = TaskSupport(store=store, queue=queue) - - # Auto-register default handlers - self._register_default_task_handlers() - - return self._task_support - - def _register_default_task_handlers(self) -> None: - """Register default handlers for task operations.""" - assert self._task_support is not None - support = self._task_support - - # Register get_task handler if not already registered - if GetTaskRequest not in self._request_handlers: - - async def _default_get_task(req: GetTaskRequest) -> ServerResult: - task = await support.store.get_task(req.params.taskId) + task_support = self._task_support + + # Register user-provided handlers + if on_get_task is not None: + self._add_request_handler("tasks/get", on_get_task) + if on_task_result is not None: + self._add_request_handler("tasks/result", on_task_result) + if on_list_tasks is not None: + self._add_request_handler("tasks/list", on_list_tasks) + if on_cancel_task is not None: + self._add_request_handler("tasks/cancel", on_cancel_task) + + # Fill in defaults for any not provided + if not self._has_handler("tasks/get"): + + async def _default_get_task( + ctx: ServerRequestContext[LifespanResultT], params: GetTaskRequestParams + ) -> GetTaskResult: + task = await task_support.store.get_task(params.task_id) if task is None: - raise McpError( - ErrorData( - code=INVALID_PARAMS, - message=f"Task not found: {req.params.taskId}", - ) - ) - return ServerResult( - GetTaskResult( - taskId=task.taskId, - status=task.status, - statusMessage=task.statusMessage, - createdAt=task.createdAt, - lastUpdatedAt=task.lastUpdatedAt, - ttl=task.ttl, - pollInterval=task.pollInterval, - ) + raise MCPError(code=INVALID_PARAMS, message=f"Task not found: {params.task_id}") + return GetTaskResult( + task_id=task.task_id, + status=task.status, + status_message=task.status_message, + created_at=task.created_at, + last_updated_at=task.last_updated_at, + ttl=task.ttl, + poll_interval=task.poll_interval, ) - self._request_handlers[GetTaskRequest] = _default_get_task + self._add_request_handler("tasks/get", _default_get_task) - # Register get_task_result handler if not already registered - if GetTaskPayloadRequest not in self._request_handlers: + if not self._has_handler("tasks/result"): - async def _default_get_task_result(req: GetTaskPayloadRequest) -> ServerResult: - ctx = self._server.request_context - result = await support.handler.handle(req, ctx.session, ctx.request_id) - return ServerResult(result) - - self._request_handlers[GetTaskPayloadRequest] = _default_get_task_result - - # Register list_tasks handler if not already registered - if ListTasksRequest not in self._request_handlers: - - async def _default_list_tasks(req: ListTasksRequest) -> ServerResult: - cursor = req.params.cursor if req.params else None - tasks, next_cursor = await support.store.list_tasks(cursor) - return ServerResult(ListTasksResult(tasks=tasks, nextCursor=next_cursor)) - - self._request_handlers[ListTasksRequest] = _default_list_tasks - - # Register cancel_task handler if not already registered - if CancelTaskRequest not in self._request_handlers: - - async def _default_cancel_task(req: CancelTaskRequest) -> ServerResult: - result = await cancel_task(support.store, req.params.taskId) - return ServerResult(result) - - self._request_handlers[CancelTaskRequest] = _default_cancel_task - - def list_tasks( - self, - ) -> Callable[ - [Callable[[ListTasksRequest], Awaitable[ListTasksResult]]], - Callable[[ListTasksRequest], Awaitable[ListTasksResult]], - ]: - """Register a handler for listing tasks. - - WARNING: This API is experimental and may change without notice. - """ + async def _default_get_task_result( + ctx: ServerRequestContext[LifespanResultT], params: GetTaskPayloadRequestParams + ) -> GetTaskPayloadResult: + assert ctx.request_id is not None + req = GetTaskPayloadRequest(params=params) + result = await task_support.handler.handle(req, ctx.session, ctx.request_id) + return result - def decorator( - func: Callable[[ListTasksRequest], Awaitable[ListTasksResult]], - ) -> Callable[[ListTasksRequest], Awaitable[ListTasksResult]]: - logger.debug("Registering handler for ListTasksRequest") - wrapper = create_call_wrapper(func, ListTasksRequest) + self._add_request_handler("tasks/result", _default_get_task_result) - async def handler(req: ListTasksRequest) -> ServerResult: - result = await wrapper(req) - return ServerResult(result) + if not self._has_handler("tasks/list"): - self._request_handlers[ListTasksRequest] = handler - return func + async def _default_list_tasks( + ctx: ServerRequestContext[LifespanResultT], params: PaginatedRequestParams | None + ) -> ListTasksResult: + cursor = params.cursor if params else None + tasks, next_cursor = await task_support.store.list_tasks(cursor) + return ListTasksResult(tasks=tasks, next_cursor=next_cursor) - return decorator - - def get_task( - self, - ) -> Callable[ - [Callable[[GetTaskRequest], Awaitable[GetTaskResult]]], Callable[[GetTaskRequest], Awaitable[GetTaskResult]] - ]: - """Register a handler for getting task status. - - WARNING: This API is experimental and may change without notice. - """ - - def decorator( - func: Callable[[GetTaskRequest], Awaitable[GetTaskResult]], - ) -> Callable[[GetTaskRequest], Awaitable[GetTaskResult]]: - logger.debug("Registering handler for GetTaskRequest") - wrapper = create_call_wrapper(func, GetTaskRequest) - - async def handler(req: GetTaskRequest) -> ServerResult: - result = await wrapper(req) - return ServerResult(result) - - self._request_handlers[GetTaskRequest] = handler - return func - - return decorator - - def get_task_result( - self, - ) -> Callable[ - [Callable[[GetTaskPayloadRequest], Awaitable[GetTaskPayloadResult]]], - Callable[[GetTaskPayloadRequest], Awaitable[GetTaskPayloadResult]], - ]: - """Register a handler for getting task results/payload. - - WARNING: This API is experimental and may change without notice. - """ - - def decorator( - func: Callable[[GetTaskPayloadRequest], Awaitable[GetTaskPayloadResult]], - ) -> Callable[[GetTaskPayloadRequest], Awaitable[GetTaskPayloadResult]]: - logger.debug("Registering handler for GetTaskPayloadRequest") - wrapper = create_call_wrapper(func, GetTaskPayloadRequest) - - async def handler(req: GetTaskPayloadRequest) -> ServerResult: - result = await wrapper(req) - return ServerResult(result) - - self._request_handlers[GetTaskPayloadRequest] = handler - return func - - return decorator - - def cancel_task( - self, - ) -> Callable[ - [Callable[[CancelTaskRequest], Awaitable[CancelTaskResult]]], - Callable[[CancelTaskRequest], Awaitable[CancelTaskResult]], - ]: - """Register a handler for cancelling tasks. - - WARNING: This API is experimental and may change without notice. - """ + self._add_request_handler("tasks/list", _default_list_tasks) - def decorator( - func: Callable[[CancelTaskRequest], Awaitable[CancelTaskResult]], - ) -> Callable[[CancelTaskRequest], Awaitable[CancelTaskResult]]: - logger.debug("Registering handler for CancelTaskRequest") - wrapper = create_call_wrapper(func, CancelTaskRequest) + if not self._has_handler("tasks/cancel"): - async def handler(req: CancelTaskRequest) -> ServerResult: - result = await wrapper(req) - return ServerResult(result) + async def _default_cancel_task( + ctx: ServerRequestContext[LifespanResultT], params: CancelTaskRequestParams + ) -> CancelTaskResult: + result = await cancel_task(task_support.store, params.task_id) + return result - self._request_handlers[CancelTaskRequest] = handler - return func + self._add_request_handler("tasks/cancel", _default_cancel_task) - return decorator + return task_support diff --git a/src/mcp/server/lowlevel/func_inspection.py b/src/mcp/server/lowlevel/func_inspection.py deleted file mode 100644 index 6231aa895..000000000 --- a/src/mcp/server/lowlevel/func_inspection.py +++ /dev/null @@ -1,54 +0,0 @@ -import inspect -from collections.abc import Callable -from typing import Any, TypeVar, get_type_hints - -T = TypeVar("T") -R = TypeVar("R") - - -def create_call_wrapper(func: Callable[..., R], request_type: type[T]) -> Callable[[T], R]: - """ - Create a wrapper function that knows how to call func with the request object. - - Returns a wrapper function that takes the request and calls func appropriately. - - The wrapper handles three calling patterns: - 1. Positional-only parameter typed as request_type (no default): func(req) - 2. Positional/keyword parameter typed as request_type (no default): func(**{param_name: req}) - 3. No request parameter or parameter with default: func() - """ - try: - sig = inspect.signature(func) - type_hints = get_type_hints(func) - except (ValueError, TypeError, NameError): # pragma: no cover - return lambda _: func() - - # Check for positional-only parameter typed as request_type - for param_name, param in sig.parameters.items(): - if param.kind == inspect.Parameter.POSITIONAL_ONLY: - param_type = type_hints.get(param_name) - if param_type == request_type: # pragma: no branch - # Check if it has a default - if so, treat as old style - if param.default is not inspect.Parameter.empty: # pragma: no cover - return lambda _: func() - # Found positional-only parameter with correct type and no default - return lambda req: func(req) - - # Check for any positional/keyword parameter typed as request_type - for param_name, param in sig.parameters.items(): - if param.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY): # pragma: no branch - param_type = type_hints.get(param_name) - if param_type == request_type: - # Check if it has a default - if so, treat as old style - if param.default is not inspect.Parameter.empty: # pragma: no cover - return lambda _: func() - - # Found keyword parameter with correct type and no default - # Need to capture param_name in closure properly - def make_keyword_wrapper(name: str) -> Callable[[Any], Any]: - return lambda req: func(**{name: req}) - - return make_keyword_wrapper(param_name) - - # No request parameter found - use old style - return lambda _: func() diff --git a/src/mcp/server/lowlevel/helper_types.py b/src/mcp/server/lowlevel/helper_types.py index 3d09b2505..fecc716db 100644 --- a/src/mcp/server/lowlevel/helper_types.py +++ b/src/mcp/server/lowlevel/helper_types.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Any @dataclass @@ -7,3 +8,4 @@ class ReadResourceContents: content: str | bytes mime_type: str | None = None + meta: dict[str, Any] | None = None diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index e29c021b7..aee644040 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -1,166 +1,256 @@ -""" -MCP Server Module +"""MCP Server Module This module provides a framework for creating an MCP (Model Context Protocol) server. It allows you to easily define and handle various types of requests and notifications -in an asynchronous manner. +using constructor-based handler registration. Usage: -1. Create a Server instance: - server = Server("your_server_name") - -2. Define request handlers using decorators: - @server.list_prompts() - async def handle_list_prompts(request: types.ListPromptsRequest) -> types.ListPromptsResult: - # Implementation - - @server.get_prompt() - async def handle_get_prompt( - name: str, arguments: dict[str, str] | None - ) -> types.GetPromptResult: - # Implementation - - @server.list_tools() - async def handle_list_tools(request: types.ListToolsRequest) -> types.ListToolsResult: - # Implementation - - @server.call_tool() - async def handle_call_tool( - name: str, arguments: dict | None - ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: - # Implementation - - @server.list_resource_templates() - async def handle_list_resource_templates() -> list[types.ResourceTemplate]: - # Implementation - -3. Define notification handlers if needed: - @server.progress_notification() - async def handle_progress( - progress_token: str | int, progress: float, total: float | None, - message: str | None - ) -> None: - # Implementation - -4. Run the server: +1. Define handler functions: + async def my_list_tools(ctx, params): + return types.ListToolsResult(tools=[...]) + + async def my_call_tool(ctx, params): + return types.CallToolResult(content=[...]) + +2. Create a Server instance with on_* handlers: + server = Server( + "your_server_name", + on_list_tools=my_list_tools, + on_call_tool=my_call_tool, + ) + +3. Run the server: async def main(): async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): await server.run( read_stream, write_stream, - InitializationOptions( - server_name="your_server_name", - server_version="your_version", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), + server.create_initialization_options(), ) asyncio.run(main()) -The Server class provides methods to register handlers for various MCP requests and -notifications. It automatically manages the request context and handles incoming -messages from the client. +The Server class dispatches incoming requests and notifications to registered +handler callables by method string. """ -from __future__ import annotations as _annotations +from __future__ import annotations -import base64 import contextvars -import json import logging import warnings -from collections.abc import AsyncIterator, Awaitable, Callable, Iterable +from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager -from typing import Any, Generic, TypeAlias, cast +from importlib.metadata import version as importlib_version +from typing import Any, Generic import anyio -import jsonschema from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from pydantic import AnyUrl +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.authentication import AuthenticationMiddleware +from starlette.routing import Mount, Route from typing_extensions import TypeVar -import mcp.types as types +from mcp import types +from mcp.server.auth.middleware.auth_context import AuthContextMiddleware +from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware +from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenVerifier +from mcp.server.auth.routes import build_resource_metadata_url, create_auth_routes, create_protected_resource_routes +from mcp.server.auth.settings import AuthSettings +from mcp.server.context import ServerRequestContext from mcp.server.experimental.request_context import Experimental from mcp.server.lowlevel.experimental import ExperimentalHandlers -from mcp.server.lowlevel.func_inspection import create_call_wrapper -from mcp.server.lowlevel.helper_types import ReadResourceContents from mcp.server.models import InitializationOptions from mcp.server.session import ServerSession -from mcp.shared.context import RequestContext -from mcp.shared.exceptions import McpError +from mcp.server.streamable_http import EventStore +from mcp.server.streamable_http_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager +from mcp.server.transport_security import TransportSecuritySettings +from mcp.shared.exceptions import MCPError from mcp.shared.message import ServerMessageMetadata, SessionMessage from mcp.shared.session import RequestResponder -from mcp.shared.tool_name_validation import validate_and_warn_tool_name logger = logging.getLogger(__name__) LifespanResultT = TypeVar("LifespanResultT", default=Any) -RequestT = TypeVar("RequestT", default=Any) -# type aliases for tool call results -StructuredContent: TypeAlias = dict[str, Any] -UnstructuredContent: TypeAlias = Iterable[types.ContentBlock] -CombinationContent: TypeAlias = tuple[UnstructuredContent, StructuredContent] - -# This will be properly typed in each Server instance's context -request_ctx: contextvars.ContextVar[RequestContext[ServerSession, Any, Any]] = contextvars.ContextVar("request_ctx") +request_ctx: contextvars.ContextVar[ServerRequestContext[Any]] = contextvars.ContextVar("request_ctx") class NotificationOptions: - def __init__( - self, - prompts_changed: bool = False, - resources_changed: bool = False, - tools_changed: bool = False, - ): + def __init__(self, prompts_changed: bool = False, resources_changed: bool = False, tools_changed: bool = False): self.prompts_changed = prompts_changed self.resources_changed = resources_changed self.tools_changed = tools_changed @asynccontextmanager -async def lifespan(_: Server[LifespanResultT, RequestT]) -> AsyncIterator[dict[str, Any]]: +async def lifespan(_: Server[LifespanResultT]) -> AsyncIterator[dict[str, Any]]: """Default lifespan context manager that does nothing. - Args: - server: The server instance this lifespan is managing - Returns: An empty context object """ yield {} -class Server(Generic[LifespanResultT, RequestT]): +async def _ping_handler(ctx: ServerRequestContext[Any], params: types.RequestParams | None) -> types.EmptyResult: + return types.EmptyResult() + + +class Server(Generic[LifespanResultT]): def __init__( self, name: str, + *, version: str | None = None, + title: str | None = None, + description: str | None = None, instructions: str | None = None, website_url: str | None = None, icons: list[types.Icon] | None = None, lifespan: Callable[ - [Server[LifespanResultT, RequestT]], + [Server[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT], ] = lifespan, + # Request handlers + on_list_tools: Callable[ + [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], + Awaitable[types.ListToolsResult], + ] + | None = None, + on_call_tool: Callable[ + [ServerRequestContext[LifespanResultT], types.CallToolRequestParams], + Awaitable[types.CallToolResult | types.CreateTaskResult], + ] + | None = None, + on_list_resources: Callable[ + [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], + Awaitable[types.ListResourcesResult], + ] + | None = None, + on_list_resource_templates: Callable[ + [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], + Awaitable[types.ListResourceTemplatesResult], + ] + | None = None, + on_read_resource: Callable[ + [ServerRequestContext[LifespanResultT], types.ReadResourceRequestParams], + Awaitable[types.ReadResourceResult], + ] + | None = None, + on_subscribe_resource: Callable[ + [ServerRequestContext[LifespanResultT], types.SubscribeRequestParams], + Awaitable[types.EmptyResult], + ] + | None = None, + on_unsubscribe_resource: Callable[ + [ServerRequestContext[LifespanResultT], types.UnsubscribeRequestParams], + Awaitable[types.EmptyResult], + ] + | None = None, + on_list_prompts: Callable[ + [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], + Awaitable[types.ListPromptsResult], + ] + | None = None, + on_get_prompt: Callable[ + [ServerRequestContext[LifespanResultT], types.GetPromptRequestParams], + Awaitable[types.GetPromptResult], + ] + | None = None, + on_completion: Callable[ + [ServerRequestContext[LifespanResultT], types.CompleteRequestParams], + Awaitable[types.CompleteResult], + ] + | None = None, + on_set_logging_level: Callable[ + [ServerRequestContext[LifespanResultT], types.SetLevelRequestParams], + Awaitable[types.EmptyResult], + ] + | None = None, + on_ping: Callable[ + [ServerRequestContext[LifespanResultT], types.RequestParams | None], + Awaitable[types.EmptyResult], + ] = _ping_handler, + # Notification handlers + on_roots_list_changed: Callable[ + [ServerRequestContext[LifespanResultT], types.NotificationParams | None], + Awaitable[None], + ] + | None = None, + on_progress: Callable[ + [ServerRequestContext[LifespanResultT], types.ProgressNotificationParams], + Awaitable[None], + ] + | None = None, ): self.name = name self.version = version + self.title = title + self.description = description self.instructions = instructions self.website_url = website_url self.icons = icons self.lifespan = lifespan - self.request_handlers: dict[type, Callable[..., Awaitable[types.ServerResult]]] = { - types.PingRequest: _ping_handler, - } - self.notification_handlers: dict[type, Callable[..., Awaitable[None]]] = {} - self._tool_cache: dict[str, types.Tool] = {} - self._experimental_handlers: ExperimentalHandlers | None = None + self._request_handlers: dict[str, Callable[[ServerRequestContext[LifespanResultT], Any], Awaitable[Any]]] = {} + self._notification_handlers: dict[ + str, Callable[[ServerRequestContext[LifespanResultT], Any], Awaitable[None]] + ] = {} + self._experimental_handlers: ExperimentalHandlers[LifespanResultT] | None = None + self._session_manager: StreamableHTTPSessionManager | None = None logger.debug("Initializing server %r", name) + # Populate internal handler dicts from on_* kwargs + self._request_handlers.update( + { + method: handler + for method, handler in { + "ping": on_ping, + "prompts/list": on_list_prompts, + "prompts/get": on_get_prompt, + "resources/list": on_list_resources, + "resources/templates/list": on_list_resource_templates, + "resources/read": on_read_resource, + "resources/subscribe": on_subscribe_resource, + "resources/unsubscribe": on_unsubscribe_resource, + "tools/list": on_list_tools, + "tools/call": on_call_tool, + "logging/setLevel": on_set_logging_level, + "completion/complete": on_completion, + }.items() + if handler is not None + } + ) + + self._notification_handlers.update( + { + method: handler + for method, handler in { + "notifications/roots/list_changed": on_roots_list_changed, + "notifications/progress": on_progress, + }.items() + if handler is not None + } + ) + + def _add_request_handler( + self, + method: str, + handler: Callable[[ServerRequestContext[LifespanResultT], Any], Awaitable[Any]], + ) -> None: + """Add a request handler, silently replacing any existing handler for the same method.""" + self._request_handlers[method] = handler + + def _has_handler(self, method: str) -> bool: + """Check if a handler is registered for the given method.""" + return method in self._request_handlers or method in self._notification_handlers + + # TODO: Rethink capabilities API. Currently capabilities are derived from registered + # handlers but require NotificationOptions to be passed externally for list_changed + # flags, and experimental_capabilities as a separate dict. Consider deriving capabilities + # entirely from server state (e.g. constructor params for list_changed) instead of + # requiring callers to assemble them at create_initialization_options() time. def create_initialization_options( self, notification_options: NotificationOptions | None = None, @@ -170,9 +260,7 @@ def create_initialization_options( def pkg_version(package: str) -> str: try: - from importlib.metadata import version - - return version(package) + return importlib_version(package) except Exception: # pragma: no cover pass @@ -181,6 +269,8 @@ def pkg_version(package: str) -> str: return InitializationOptions( server_name=self.name, server_version=self.version if self.version else pkg_version("mcp"), + title=self.title, + description=self.description, capabilities=self.get_capabilities( notification_options or NotificationOptions(), experimental_capabilities or {}, @@ -203,25 +293,26 @@ def get_capabilities( completions_capability = None # Set prompt capabilities if handler exists - if types.ListPromptsRequest in self.request_handlers: - prompts_capability = types.PromptsCapability(listChanged=notification_options.prompts_changed) + if "prompts/list" in self._request_handlers: + prompts_capability = types.PromptsCapability(list_changed=notification_options.prompts_changed) # Set resource capabilities if handler exists - if types.ListResourcesRequest in self.request_handlers: + if "resources/list" in self._request_handlers: resources_capability = types.ResourcesCapability( - subscribe=False, listChanged=notification_options.resources_changed + subscribe="resources/subscribe" in self._request_handlers, + list_changed=notification_options.resources_changed, ) # Set tool capabilities if handler exists - if types.ListToolsRequest in self.request_handlers: - tools_capability = types.ToolsCapability(listChanged=notification_options.tools_changed) + if "tools/list" in self._request_handlers: + tools_capability = types.ToolsCapability(list_changed=notification_options.tools_changed) # Set logging capabilities if handler exists - if types.SetLevelRequest in self.request_handlers: # pragma: no cover + if "logging/setLevel" in self._request_handlers: logging_capability = types.LoggingCapability() # Set completions capabilities if handler exists - if types.CompleteRequest in self.request_handlers: + if "completion/complete" in self._request_handlers: completions_capability = types.CompletionsCapability() capabilities = types.ServerCapabilities( @@ -237,14 +328,7 @@ def get_capabilities( return capabilities @property - def request_context( - self, - ) -> RequestContext[ServerSession, LifespanResultT, RequestT]: - """If called outside of a request context, this will raise a LookupError.""" - return request_ctx.get() - - @property - def experimental(self) -> ExperimentalHandlers: + def experimental(self) -> ExperimentalHandlers[LifespanResultT]: """Experimental APIs for tasks and other features. WARNING: These APIs are experimental and may change without notice. @@ -252,379 +336,25 @@ def experimental(self) -> ExperimentalHandlers: # We create this inline so we only add these capabilities _if_ they're actually used if self._experimental_handlers is None: - self._experimental_handlers = ExperimentalHandlers(self, self.request_handlers, self.notification_handlers) - return self._experimental_handlers - - def list_prompts(self): - def decorator( - func: Callable[[], Awaitable[list[types.Prompt]]] - | Callable[[types.ListPromptsRequest], Awaitable[types.ListPromptsResult]], - ): - logger.debug("Registering handler for PromptListRequest") - - wrapper = create_call_wrapper(func, types.ListPromptsRequest) - - async def handler(req: types.ListPromptsRequest): - result = await wrapper(req) - # Handle both old style (list[Prompt]) and new style (ListPromptsResult) - if isinstance(result, types.ListPromptsResult): - return types.ServerResult(result) - else: - # Old style returns list[Prompt] - return types.ServerResult(types.ListPromptsResult(prompts=result)) - - self.request_handlers[types.ListPromptsRequest] = handler - return func - - return decorator - - def get_prompt(self): - def decorator( - func: Callable[[str, dict[str, str] | None], Awaitable[types.GetPromptResult]], - ): - logger.debug("Registering handler for GetPromptRequest") - - async def handler(req: types.GetPromptRequest): - prompt_get = await func(req.params.name, req.params.arguments) - return types.ServerResult(prompt_get) - - self.request_handlers[types.GetPromptRequest] = handler - return func - - return decorator - - def list_resources(self): - def decorator( - func: Callable[[], Awaitable[list[types.Resource]]] - | Callable[[types.ListResourcesRequest], Awaitable[types.ListResourcesResult]], - ): - logger.debug("Registering handler for ListResourcesRequest") - - wrapper = create_call_wrapper(func, types.ListResourcesRequest) - - async def handler(req: types.ListResourcesRequest): - result = await wrapper(req) - # Handle both old style (list[Resource]) and new style (ListResourcesResult) - if isinstance(result, types.ListResourcesResult): - return types.ServerResult(result) - else: - # Old style returns list[Resource] - return types.ServerResult(types.ListResourcesResult(resources=result)) - - self.request_handlers[types.ListResourcesRequest] = handler - return func - - return decorator - - def list_resource_templates(self): - def decorator(func: Callable[[], Awaitable[list[types.ResourceTemplate]]]): - logger.debug("Registering handler for ListResourceTemplatesRequest") - - async def handler(_: Any): - templates = await func() - return types.ServerResult(types.ListResourceTemplatesResult(resourceTemplates=templates)) - - self.request_handlers[types.ListResourceTemplatesRequest] = handler - return func - - return decorator - - def read_resource(self): - def decorator( - func: Callable[[AnyUrl], Awaitable[str | bytes | Iterable[ReadResourceContents]]], - ): - logger.debug("Registering handler for ReadResourceRequest") - - async def handler(req: types.ReadResourceRequest): - result = await func(req.params.uri) - - def create_content(data: str | bytes, mime_type: str | None): - match data: - case str() as data: - return types.TextResourceContents( - uri=req.params.uri, - text=data, - mimeType=mime_type or "text/plain", - ) - case bytes() as data: # pragma: no cover - return types.BlobResourceContents( - uri=req.params.uri, - blob=base64.b64encode(data).decode(), - mimeType=mime_type or "application/octet-stream", - ) - - match result: - case str() | bytes() as data: # pragma: no cover - warnings.warn( - "Returning str or bytes from read_resource is deprecated. " - "Use Iterable[ReadResourceContents] instead.", - DeprecationWarning, - stacklevel=2, - ) - content = create_content(data, None) - case Iterable() as contents: - contents_list = [ - create_content(content_item.content, content_item.mime_type) for content_item in contents - ] - return types.ServerResult( - types.ReadResourceResult( - contents=contents_list, - ) - ) - case _: # pragma: no cover - raise ValueError(f"Unexpected return type from read_resource: {type(result)}") - - return types.ServerResult( # pragma: no cover - types.ReadResourceResult( - contents=[content], - ) - ) - - self.request_handlers[types.ReadResourceRequest] = handler - return func - - return decorator - - def set_logging_level(self): # pragma: no cover - def decorator(func: Callable[[types.LoggingLevel], Awaitable[None]]): - logger.debug("Registering handler for SetLevelRequest") - - async def handler(req: types.SetLevelRequest): - await func(req.params.level) - return types.ServerResult(types.EmptyResult()) - - self.request_handlers[types.SetLevelRequest] = handler - return func - - return decorator - - def subscribe_resource(self): # pragma: no cover - def decorator(func: Callable[[AnyUrl], Awaitable[None]]): - logger.debug("Registering handler for SubscribeRequest") - - async def handler(req: types.SubscribeRequest): - await func(req.params.uri) - return types.ServerResult(types.EmptyResult()) - - self.request_handlers[types.SubscribeRequest] = handler - return func - - return decorator - - def unsubscribe_resource(self): # pragma: no cover - def decorator(func: Callable[[AnyUrl], Awaitable[None]]): - logger.debug("Registering handler for UnsubscribeRequest") - - async def handler(req: types.UnsubscribeRequest): - await func(req.params.uri) - return types.ServerResult(types.EmptyResult()) - - self.request_handlers[types.UnsubscribeRequest] = handler - return func - - return decorator - - def list_tools(self): - def decorator( - func: Callable[[], Awaitable[list[types.Tool]]] - | Callable[[types.ListToolsRequest], Awaitable[types.ListToolsResult]], - ): - logger.debug("Registering handler for ListToolsRequest") - - wrapper = create_call_wrapper(func, types.ListToolsRequest) - - async def handler(req: types.ListToolsRequest): - result = await wrapper(req) - - # Handle both old style (list[Tool]) and new style (ListToolsResult) - if isinstance(result, types.ListToolsResult): # pragma: no cover - # Refresh the tool cache with returned tools - for tool in result.tools: - validate_and_warn_tool_name(tool.name) - self._tool_cache[tool.name] = tool - return types.ServerResult(result) - else: - # Old style returns list[Tool] - # Clear and refresh the entire tool cache - self._tool_cache.clear() - for tool in result: - validate_and_warn_tool_name(tool.name) - self._tool_cache[tool.name] = tool - return types.ServerResult(types.ListToolsResult(tools=result)) - - self.request_handlers[types.ListToolsRequest] = handler - return func - - return decorator - - def _make_error_result(self, error_message: str) -> types.ServerResult: - """Create a ServerResult with an error CallToolResult.""" - return types.ServerResult( - types.CallToolResult( - content=[types.TextContent(type="text", text=error_message)], - isError=True, + self._experimental_handlers = ExperimentalHandlers( + add_request_handler=self._add_request_handler, + has_handler=self._has_handler, ) - ) - - async def _get_cached_tool_definition(self, tool_name: str) -> types.Tool | None: - """Get tool definition from cache, refreshing if necessary. - - Returns the Tool object if found, None otherwise. - """ - if tool_name not in self._tool_cache: - if types.ListToolsRequest in self.request_handlers: - logger.debug("Tool cache miss for %s, refreshing cache", tool_name) - await self.request_handlers[types.ListToolsRequest](None) - - tool = self._tool_cache.get(tool_name) - if tool is None: - logger.warning("Tool '%s' not listed, no validation will be performed", tool_name) - - return tool - - def call_tool(self, *, validate_input: bool = True): - """Register a tool call handler. - - Args: - validate_input: If True, validates input against inputSchema. Default is True. + return self._experimental_handlers - The handler validates input against inputSchema (if validate_input=True), calls the tool function, - and builds a CallToolResult with the results: - - Unstructured content (iterable of ContentBlock): returned in content - - Structured content (dict): returned in structuredContent, serialized JSON text returned in content - - Both: returned in content and structuredContent + @property + def session_manager(self) -> StreamableHTTPSessionManager: + """Get the StreamableHTTP session manager. - If outputSchema is defined, validates structuredContent or errors if missing. + Raises: + RuntimeError: If called before streamable_http_app() has been called. """ - - def decorator( - func: Callable[ - ..., - Awaitable[ - UnstructuredContent - | StructuredContent - | CombinationContent - | types.CallToolResult - | types.CreateTaskResult - ], - ], - ): - logger.debug("Registering handler for CallToolRequest") - - async def handler(req: types.CallToolRequest): - try: - tool_name = req.params.name - arguments = req.params.arguments or {} - tool = await self._get_cached_tool_definition(tool_name) - - # input validation - if validate_input and tool: - try: - jsonschema.validate(instance=arguments, schema=tool.inputSchema) - except jsonschema.ValidationError as e: - return self._make_error_result(f"Input validation error: {e.message}") - - # tool call - results = await func(tool_name, arguments) - - # output normalization - unstructured_content: UnstructuredContent - maybe_structured_content: StructuredContent | None - if isinstance(results, types.CallToolResult): - return types.ServerResult(results) - elif isinstance(results, types.CreateTaskResult): - # Task-augmented execution returns task info instead of result - return types.ServerResult(results) - elif isinstance(results, tuple) and len(results) == 2: - # tool returned both structured and unstructured content - unstructured_content, maybe_structured_content = cast(CombinationContent, results) - elif isinstance(results, dict): - # tool returned structured content only - maybe_structured_content = cast(StructuredContent, results) - unstructured_content = [types.TextContent(type="text", text=json.dumps(results, indent=2))] - elif hasattr(results, "__iter__"): # pragma: no cover - # tool returned unstructured content only - unstructured_content = cast(UnstructuredContent, results) - maybe_structured_content = None - else: # pragma: no cover - return self._make_error_result(f"Unexpected return type from tool: {type(results).__name__}") - - # output validation - if tool and tool.outputSchema is not None: - if maybe_structured_content is None: - return self._make_error_result( - "Output validation error: outputSchema defined but no structured output returned" - ) - else: - try: - jsonschema.validate(instance=maybe_structured_content, schema=tool.outputSchema) - except jsonschema.ValidationError as e: - return self._make_error_result(f"Output validation error: {e.message}") - - # result - return types.ServerResult( - types.CallToolResult( - content=list(unstructured_content), - structuredContent=maybe_structured_content, - isError=False, - ) - ) - except Exception as e: - return self._make_error_result(str(e)) - - self.request_handlers[types.CallToolRequest] = handler - return func - - return decorator - - def progress_notification(self): - def decorator( - func: Callable[[str | int, float, float | None, str | None], Awaitable[None]], - ): - logger.debug("Registering handler for ProgressNotification") - - async def handler(req: types.ProgressNotification): - await func( - req.params.progressToken, - req.params.progress, - req.params.total, - req.params.message, - ) - - self.notification_handlers[types.ProgressNotification] = handler - return func - - return decorator - - def completion(self): - """Provides completions for prompts and resource templates""" - - def decorator( - func: Callable[ - [ - types.PromptReference | types.ResourceTemplateReference, - types.CompletionArgument, - types.CompletionContext | None, - ], - Awaitable[types.Completion | None], - ], - ): - logger.debug("Registering handler for CompleteRequest") - - async def handler(req: types.CompleteRequest): - completion = await func(req.params.ref, req.params.argument, req.params.context) - return types.ServerResult( - types.CompleteResult( - completion=completion - if completion is not None - else types.Completion(values=[], total=None, hasMore=None), - ) - ) - - self.request_handlers[types.CompleteRequest] = handler - return func - - return decorator + if self._session_manager is None: # pragma: no cover + raise RuntimeError( + "Session manager can only be accessed after calling streamable_http_app(). " + "The session manager is created lazily to avoid unnecessary initialization." + ) + return self._session_manager # pragma: no cover async def run( self, @@ -680,12 +410,12 @@ async def _handle_message( ): with warnings.catch_warnings(record=True) as w: match message: - case RequestResponder(request=types.ClientRequest(root=req)) as responder: + case RequestResponder() as responder: with responder: - await self._handle_request(message, req, session, lifespan_context, raise_exceptions) - case types.ClientNotification(root=notify): - await self._handle_notification(notify) - case Exception(): # pragma: no cover + await self._handle_request( + message, responder.request, session, lifespan_context, raise_exceptions + ) + case Exception(): logger.error(f"Received exception from stream: {message}") await session.send_log_message( level="error", @@ -694,99 +424,211 @@ async def _handle_message( ) if raise_exceptions: raise message + case _: + await self._handle_notification(message, session, lifespan_context) - for warning in w: # pragma: no cover + for warning in w: # pragma: lax no cover logger.info("Warning: %s: %s", warning.category.__name__, warning.message) async def _handle_request( self, message: RequestResponder[types.ClientRequest, types.ServerResult], - req: types.ClientRequestType, + req: types.ClientRequest, session: ServerSession, lifespan_context: LifespanResultT, raise_exceptions: bool, ): logger.info("Processing request of type %s", type(req).__name__) - if handler := self.request_handlers.get(type(req)): + if handler := self._request_handlers.get(req.method): logger.debug("Dispatching request of type %s", type(req).__name__) - token = None try: # Extract request context and close_sse_stream from message metadata request_data = None close_sse_stream_cb = None close_standalone_sse_stream_cb = None - if message.message_metadata is not None and isinstance( - message.message_metadata, ServerMessageMetadata - ): # pragma: no cover + if message.message_metadata is not None and isinstance(message.message_metadata, ServerMessageMetadata): request_data = message.message_metadata.request_context close_sse_stream_cb = message.message_metadata.close_sse_stream close_standalone_sse_stream_cb = message.message_metadata.close_standalone_sse_stream - # Set our global state that can be retrieved via - # app.get_request_context() client_capabilities = session.client_params.capabilities if session.client_params else None task_support = self._experimental_handlers.task_support if self._experimental_handlers else None # Get task metadata from request params if present task_metadata = None if hasattr(req, "params") and req.params is not None: task_metadata = getattr(req.params, "task", None) - token = request_ctx.set( - RequestContext( - message.request_id, - message.request_meta, - session, - lifespan_context, - Experimental( - task_metadata=task_metadata, - _client_capabilities=client_capabilities, - _session=session, - _task_support=task_support, - ), - request=request_data, - close_sse_stream=close_sse_stream_cb, - close_standalone_sse_stream=close_standalone_sse_stream_cb, - ) + ctx = ServerRequestContext( + request_id=message.request_id, + meta=message.request_meta, + session=session, + lifespan_context=lifespan_context, + experimental=Experimental( + task_metadata=task_metadata, + _client_capabilities=client_capabilities, + _session=session, + _task_support=task_support, + ), + request=request_data, + close_sse_stream=close_sse_stream_cb, + close_standalone_sse_stream=close_standalone_sse_stream_cb, ) - response = await handler(req) - except McpError as err: # pragma: no cover + token = request_ctx.set(ctx) + try: + response = await handler(ctx, req.params) + finally: + request_ctx.reset(token) + except MCPError as err: response = err.error - except anyio.get_cancelled_exc_class(): # pragma: no cover - logger.info( - "Request %s cancelled - duplicate response suppressed", - message.request_id, - ) + except anyio.get_cancelled_exc_class(): + logger.info("Request %s cancelled - duplicate response suppressed", message.request_id) return - except Exception as err: # pragma: no cover - if raise_exceptions: + except Exception as err: + if raise_exceptions: # pragma: no cover raise err - response = types.ErrorData(code=0, message=str(err), data=None) - finally: - # Reset the global state after we are done - if token is not None: # pragma: no branch - request_ctx.reset(token) + response = types.ErrorData(code=0, message=str(err)) await message.respond(response) else: # pragma: no cover - await message.respond( - types.ErrorData( - code=types.METHOD_NOT_FOUND, - message="Method not found", - ) - ) + await message.respond(types.ErrorData(code=types.METHOD_NOT_FOUND, message="Method not found")) logger.debug("Response sent") - async def _handle_notification(self, notify: Any): - if handler := self.notification_handlers.get(type(notify)): # type: ignore + async def _handle_notification( + self, + notify: types.ClientNotification, + session: ServerSession, + lifespan_context: LifespanResultT, + ) -> None: + if handler := self._notification_handlers.get(notify.method): logger.debug("Dispatching notification of type %s", type(notify).__name__) try: - await handler(notify) + client_capabilities = session.client_params.capabilities if session.client_params else None + task_support = self._experimental_handlers.task_support if self._experimental_handlers else None + ctx = ServerRequestContext( + session=session, + lifespan_context=lifespan_context, + experimental=Experimental( + task_metadata=None, + _client_capabilities=client_capabilities, + _session=session, + _task_support=task_support, + ), + ) + await handler(ctx, notify.params) except Exception: # pragma: no cover logger.exception("Uncaught exception in notification handler") + def streamable_http_app( + self, + *, + streamable_http_path: str = "/mcp", + json_response: bool = False, + stateless_http: bool = False, + event_store: EventStore | None = None, + retry_interval: int | None = None, + transport_security: TransportSecuritySettings | None = None, + host: str = "127.0.0.1", + auth: AuthSettings | None = None, + token_verifier: TokenVerifier | None = None, + auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None, + custom_starlette_routes: list[Route] | None = None, + debug: bool = False, + ) -> Starlette: + """Return an instance of the StreamableHTTP server app.""" + # Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6) + if transport_security is None and host in ("127.0.0.1", "localhost", "::1"): + transport_security = TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"], + allowed_origins=["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"], + ) -async def _ping_handler(request: types.PingRequest) -> types.ServerResult: - return types.ServerResult(types.EmptyResult()) + session_manager = StreamableHTTPSessionManager( + app=self, + event_store=event_store, + retry_interval=retry_interval, + json_response=json_response, + stateless=stateless_http, + security_settings=transport_security, + ) + self._session_manager = session_manager + + # Create the ASGI handler + streamable_http_app = StreamableHTTPASGIApp(session_manager) + + # Create routes + routes: list[Route | Mount] = [] + middleware: list[Middleware] = [] + required_scopes: list[str] = [] + + # Set up auth if configured + if auth: # pragma: no cover + required_scopes = auth.required_scopes or [] + + # Add auth middleware if token verifier is available + if token_verifier: + middleware = [ + Middleware( + AuthenticationMiddleware, + backend=BearerAuthBackend(token_verifier), + ), + Middleware(AuthContextMiddleware), + ] + + # Add auth endpoints if auth server provider is configured + if auth_server_provider: + routes.extend( + create_auth_routes( + provider=auth_server_provider, + issuer_url=auth.issuer_url, + service_documentation_url=auth.service_documentation_url, + client_registration_options=auth.client_registration_options, + revocation_options=auth.revocation_options, + ) + ) + + # Set up routes with or without auth + if token_verifier: # pragma: no cover + # Determine resource metadata URL + resource_metadata_url = None + if auth and auth.resource_server_url: + # Build compliant metadata URL for WWW-Authenticate header + resource_metadata_url = build_resource_metadata_url(auth.resource_server_url) + + routes.append( + Route( + streamable_http_path, + endpoint=RequireAuthMiddleware(streamable_http_app, required_scopes, resource_metadata_url), + ) + ) + else: + # Auth is disabled, no wrapper needed + routes.append( + Route( + streamable_http_path, + endpoint=streamable_http_app, + ) + ) + + # Add protected resource metadata endpoint if configured as RS + if auth and auth.resource_server_url: # pragma: no cover + routes.extend( + create_protected_resource_routes( + resource_url=auth.resource_server_url, + authorization_servers=[auth.issuer_url], + scopes_supported=auth.required_scopes, + ) + ) + + if custom_starlette_routes: # pragma: no cover + routes.extend(custom_starlette_routes) + + return Starlette( + debug=debug, + routes=routes, + middleware=middleware, + lifespan=lambda app: session_manager.run(), + ) diff --git a/src/mcp/server/mcpserver/__init__.py b/src/mcp/server/mcpserver/__init__.py new file mode 100644 index 000000000..f51c0b0ed --- /dev/null +++ b/src/mcp/server/mcpserver/__init__.py @@ -0,0 +1,8 @@ +"""MCPServer - A more ergonomic interface for MCP servers.""" + +from mcp.types import Icon + +from .server import Context, MCPServer +from .utilities.types import Audio, Image + +__all__ = ["MCPServer", "Context", "Image", "Audio", "Icon"] diff --git a/src/mcp/server/mcpserver/exceptions.py b/src/mcp/server/mcpserver/exceptions.py new file mode 100644 index 000000000..dd1b75e82 --- /dev/null +++ b/src/mcp/server/mcpserver/exceptions.py @@ -0,0 +1,21 @@ +"""Custom exceptions for MCPServer.""" + + +class MCPServerError(Exception): + """Base error for MCPServer.""" + + +class ValidationError(MCPServerError): + """Error in validating parameters or return values.""" + + +class ResourceError(MCPServerError): + """Error in resource operations.""" + + +class ToolError(MCPServerError): + """Error in tool operations.""" + + +class InvalidSignature(Exception): + """Invalid signature for use with MCPServer.""" diff --git a/src/mcp/server/fastmcp/prompts/__init__.py b/src/mcp/server/mcpserver/prompts/__init__.py similarity index 100% rename from src/mcp/server/fastmcp/prompts/__init__.py rename to src/mcp/server/mcpserver/prompts/__init__.py diff --git a/src/mcp/server/fastmcp/prompts/base.py b/src/mcp/server/mcpserver/prompts/base.py similarity index 93% rename from src/mcp/server/fastmcp/prompts/base.py rename to src/mcp/server/mcpserver/prompts/base.py index 48c65b57c..17744a670 100644 --- a/src/mcp/server/fastmcp/prompts/base.py +++ b/src/mcp/server/mcpserver/prompts/base.py @@ -1,4 +1,4 @@ -"""Base classes for FastMCP prompts.""" +"""Base classes for MCPServer prompts.""" from __future__ import annotations @@ -9,14 +9,13 @@ import pydantic_core from pydantic import BaseModel, Field, TypeAdapter, validate_call -from mcp.server.fastmcp.utilities.context_injection import find_context_parameter, inject_context -from mcp.server.fastmcp.utilities.func_metadata import func_metadata +from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context +from mcp.server.mcpserver.utilities.func_metadata import func_metadata from mcp.types import ContentBlock, Icon, TextContent if TYPE_CHECKING: - from mcp.server.fastmcp.server import Context - from mcp.server.session import ServerSessionT - from mcp.shared.context import LifespanContextT, RequestT + from mcp.server.context import LifespanContextT, RequestT + from mcp.server.mcpserver.server import Context class Message(BaseModel): @@ -137,7 +136,7 @@ def from_function( async def render( self, arguments: dict[str, Any] | None = None, - context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None, + context: Context[LifespanContextT, RequestT] | None = None, ) -> list[Message]: """Render the prompt with arguments.""" # Validate required arguments diff --git a/src/mcp/server/fastmcp/prompts/manager.py b/src/mcp/server/mcpserver/prompts/manager.py similarity index 78% rename from src/mcp/server/fastmcp/prompts/manager.py rename to src/mcp/server/mcpserver/prompts/manager.py index 6d032c73a..21b974131 100644 --- a/src/mcp/server/fastmcp/prompts/manager.py +++ b/src/mcp/server/mcpserver/prompts/manager.py @@ -4,19 +4,18 @@ from typing import TYPE_CHECKING, Any -from mcp.server.fastmcp.prompts.base import Message, Prompt -from mcp.server.fastmcp.utilities.logging import get_logger +from mcp.server.mcpserver.prompts.base import Message, Prompt +from mcp.server.mcpserver.utilities.logging import get_logger if TYPE_CHECKING: - from mcp.server.fastmcp.server import Context - from mcp.server.session import ServerSessionT - from mcp.shared.context import LifespanContextT, RequestT + from mcp.server.context import LifespanContextT, RequestT + from mcp.server.mcpserver.server import Context logger = get_logger(__name__) class PromptManager: - """Manages FastMCP prompts.""" + """Manages MCPServer prompts.""" def __init__(self, warn_on_duplicate_prompts: bool = True): self._prompts: dict[str, Prompt] = {} @@ -50,7 +49,7 @@ async def render_prompt( self, name: str, arguments: dict[str, Any] | None = None, - context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None, + context: Context[LifespanContextT, RequestT] | None = None, ) -> list[Message]: """Render a prompt by name with arguments.""" prompt = self.get_prompt(name) diff --git a/src/mcp/server/fastmcp/resources/__init__.py b/src/mcp/server/mcpserver/resources/__init__.py similarity index 100% rename from src/mcp/server/fastmcp/resources/__init__.py rename to src/mcp/server/mcpserver/resources/__init__.py diff --git a/src/mcp/server/fastmcp/resources/base.py b/src/mcp/server/mcpserver/resources/base.py similarity index 85% rename from src/mcp/server/fastmcp/resources/base.py rename to src/mcp/server/mcpserver/resources/base.py index 557775eab..d3ccc425e 100644 --- a/src/mcp/server/fastmcp/resources/base.py +++ b/src/mcp/server/mcpserver/resources/base.py @@ -1,14 +1,12 @@ -"""Base classes and interfaces for FastMCP resources.""" +"""Base classes and interfaces for MCPServer resources.""" import abc -from typing import Annotated +from typing import Any from pydantic import ( - AnyUrl, BaseModel, ConfigDict, Field, - UrlConstraints, ValidationInfo, field_validator, ) @@ -21,7 +19,7 @@ class Resource(BaseModel, abc.ABC): model_config = ConfigDict(validate_default=True) - uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] = Field(default=..., description="URI of the resource") + uri: str = Field(default=..., description="URI of the resource") name: str | None = Field(description="Name of the resource", default=None) title: str | None = Field(description="Human-readable title of the resource", default=None) description: str | None = Field(description="Description of the resource", default=None) @@ -32,6 +30,7 @@ class Resource(BaseModel, abc.ABC): ) icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this resource") annotations: Annotations | None = Field(default=None, description="Optional annotations for the resource") + meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this resource") @field_validator("name", mode="before") @classmethod diff --git a/src/mcp/server/fastmcp/resources/resource_manager.py b/src/mcp/server/mcpserver/resources/resource_manager.py similarity index 86% rename from src/mcp/server/fastmcp/resources/resource_manager.py rename to src/mcp/server/mcpserver/resources/resource_manager.py index 2e7dc171b..ed5b74123 100644 --- a/src/mcp/server/fastmcp/resources/resource_manager.py +++ b/src/mcp/server/mcpserver/resources/resource_manager.py @@ -7,21 +7,20 @@ from pydantic import AnyUrl -from mcp.server.fastmcp.resources.base import Resource -from mcp.server.fastmcp.resources.templates import ResourceTemplate -from mcp.server.fastmcp.utilities.logging import get_logger +from mcp.server.mcpserver.resources.base import Resource +from mcp.server.mcpserver.resources.templates import ResourceTemplate +from mcp.server.mcpserver.utilities.logging import get_logger from mcp.types import Annotations, Icon if TYPE_CHECKING: - from mcp.server.fastmcp.server import Context - from mcp.server.session import ServerSessionT - from mcp.shared.context import LifespanContextT, RequestT + from mcp.server.context import LifespanContextT, RequestT + from mcp.server.mcpserver.server import Context logger = get_logger(__name__) class ResourceManager: - """Manages FastMCP resources.""" + """Manages MCPServer resources.""" def __init__(self, warn_on_duplicate_resources: bool = True): self._resources: dict[str, Resource] = {} @@ -64,6 +63,7 @@ def add_template( mime_type: str | None = None, icons: list[Icon] | None = None, annotations: Annotations | None = None, + meta: dict[str, Any] | None = None, ) -> ResourceTemplate: """Add a template from a function.""" template = ResourceTemplate.from_function( @@ -75,15 +75,14 @@ def add_template( mime_type=mime_type, icons=icons, annotations=annotations, + meta=meta, ) self._templates[template.uri_template] = template return template async def get_resource( - self, - uri: AnyUrl | str, - context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None, - ) -> Resource | None: + self, uri: AnyUrl | str, context: Context[LifespanContextT, RequestT] | None = None + ) -> Resource: """Get resource by URI, checking concrete resources first, then templates.""" uri_str = str(uri) logger.debug("Getting resource", extra={"uri": uri_str}) diff --git a/src/mcp/server/fastmcp/resources/templates.py b/src/mcp/server/mcpserver/resources/templates.py similarity index 82% rename from src/mcp/server/fastmcp/resources/templates.py rename to src/mcp/server/mcpserver/resources/templates.py index a98d37f0a..e796823d9 100644 --- a/src/mcp/server/fastmcp/resources/templates.py +++ b/src/mcp/server/mcpserver/resources/templates.py @@ -6,18 +6,18 @@ import re from collections.abc import Callable from typing import TYPE_CHECKING, Any +from urllib.parse import unquote from pydantic import BaseModel, Field, validate_call -from mcp.server.fastmcp.resources.types import FunctionResource, Resource -from mcp.server.fastmcp.utilities.context_injection import find_context_parameter, inject_context -from mcp.server.fastmcp.utilities.func_metadata import func_metadata +from mcp.server.mcpserver.resources.types import FunctionResource, Resource +from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context +from mcp.server.mcpserver.utilities.func_metadata import func_metadata from mcp.types import Annotations, Icon if TYPE_CHECKING: - from mcp.server.fastmcp.server import Context - from mcp.server.session import ServerSessionT - from mcp.shared.context import LifespanContextT, RequestT + from mcp.server.context import LifespanContextT, RequestT + from mcp.server.mcpserver.server import Context class ResourceTemplate(BaseModel): @@ -30,6 +30,7 @@ class ResourceTemplate(BaseModel): mime_type: str = Field(default="text/plain", description="MIME type of the resource content") icons: list[Icon] | None = Field(default=None, description="Optional list of icons for the resource template") annotations: Annotations | None = Field(default=None, description="Optional annotations for the resource template") + meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this resource template") fn: Callable[..., Any] = Field(exclude=True) parameters: dict[str, Any] = Field(description="JSON schema for function parameters") context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context") @@ -45,6 +46,7 @@ def from_function( mime_type: str | None = None, icons: list[Icon] | None = None, annotations: Annotations | None = None, + meta: dict[str, Any] | None = None, context_kwarg: str | None = None, ) -> ResourceTemplate: """Create a template from a function.""" @@ -74,25 +76,30 @@ def from_function( mime_type=mime_type or "text/plain", icons=icons, annotations=annotations, + meta=meta, fn=fn, parameters=parameters, context_kwarg=context_kwarg, ) def matches(self, uri: str) -> dict[str, Any] | None: - """Check if URI matches template and extract parameters.""" + """Check if URI matches template and extract parameters. + + Extracted parameters are URL-decoded to handle percent-encoded characters. + """ # Convert template to regex pattern pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)") match = re.match(f"^{pattern}$", uri) if match: - return match.groupdict() + # URL-decode all extracted parameter values + return {key: unquote(value) for key, value in match.groupdict().items()} return None async def create_resource( self, uri: str, params: dict[str, Any], - context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None, + context: Context[LifespanContextT, RequestT] | None = None, ) -> Resource: """Create a resource from the template with the given parameters.""" try: @@ -112,6 +119,7 @@ async def create_resource( mime_type=self.mime_type, icons=self.icons, annotations=self.annotations, + meta=self.meta, fn=lambda: result, # Capture result in closure ) except Exception as e: diff --git a/src/mcp/server/fastmcp/resources/types.py b/src/mcp/server/mcpserver/resources/types.py similarity index 95% rename from src/mcp/server/fastmcp/resources/types.py rename to src/mcp/server/mcpserver/resources/types.py index 680e72dc0..42aecd6e3 100644 --- a/src/mcp/server/fastmcp/resources/types.py +++ b/src/mcp/server/mcpserver/resources/types.py @@ -11,9 +11,9 @@ import httpx import pydantic import pydantic_core -from pydantic import AnyUrl, Field, ValidationInfo, validate_call +from pydantic import Field, ValidationInfo, validate_call -from mcp.server.fastmcp.resources.base import Resource +from mcp.server.mcpserver.resources.base import Resource from mcp.types import Annotations, Icon @@ -83,6 +83,7 @@ def from_function( mime_type: str | None = None, icons: list[Icon] | None = None, annotations: Annotations | None = None, + meta: dict[str, Any] | None = None, ) -> "FunctionResource": """Create a FunctionResource from a function.""" func_name = name or fn.__name__ @@ -93,7 +94,7 @@ def from_function( fn = validate_call(fn) return cls( - uri=AnyUrl(uri), + uri=uri, name=func_name, title=title, description=description or fn.__doc__ or "", @@ -101,13 +102,14 @@ def from_function( fn=fn, icons=icons, annotations=annotations, + meta=meta, ) class FileResource(Resource): """A resource that reads from a file. - Set is_binary=True to read file as binary data instead of text. + Set is_binary=True to read the file as binary data instead of text. """ path: Path = Field(description="Path to the file") @@ -122,7 +124,7 @@ class FileResource(Resource): @pydantic.field_validator("path") @classmethod - def validate_absolute_path(cls, path: Path) -> Path: # pragma: no cover + def validate_absolute_path(cls, path: Path) -> Path: """Ensure path is absolute.""" if not path.is_absolute(): raise ValueError("Path must be absolute") diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/mcpserver/server.py similarity index 67% rename from src/mcp/server/fastmcp/server.py rename to src/mcp/server/mcpserver/server.py index f74b65557..9c7105a7b 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -1,19 +1,14 @@ -"""FastMCP - A more ergonomic interface for MCP servers.""" +"""MCPServer - A more ergonomic interface for MCP servers.""" -from __future__ import annotations as _annotations +from __future__ import annotations +import base64 import inspect +import json import re -from collections.abc import ( - AsyncIterator, - Awaitable, - Callable, - Collection, - Iterable, - Sequence, -) +from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence from contextlib import AbstractAsyncContextManager, asynccontextmanager -from typing import Any, Generic, Literal +from typing import Any, Generic, Literal, TypeVar, overload import anyio import pydantic_core @@ -29,43 +24,50 @@ from starlette.types import Receive, Scope, Send from mcp.server.auth.middleware.auth_context import AuthContextMiddleware -from mcp.server.auth.middleware.bearer_auth import ( - BearerAuthBackend, - RequireAuthMiddleware, -) -from mcp.server.auth.provider import ( - OAuthAuthorizationServerProvider, - ProviderTokenVerifier, - TokenVerifier, -) +from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware +from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier from mcp.server.auth.settings import AuthSettings -from mcp.server.elicitation import ( - ElicitationResult, - ElicitSchemaModelT, - UrlElicitationResult, - elicit_with_validation, -) -from mcp.server.elicitation import ( - elicit_url as _elicit_url, -) -from mcp.server.fastmcp.exceptions import ResourceError -from mcp.server.fastmcp.prompts import Prompt, PromptManager -from mcp.server.fastmcp.resources import FunctionResource, Resource, ResourceManager -from mcp.server.fastmcp.tools import Tool, ToolManager -from mcp.server.fastmcp.utilities.context_injection import find_context_parameter -from mcp.server.fastmcp.utilities.logging import configure_logging, get_logger +from mcp.server.context import LifespanContextT, RequestT, ServerRequestContext +from mcp.server.elicitation import ElicitationResult, ElicitSchemaModelT, UrlElicitationResult, elicit_with_validation +from mcp.server.elicitation import elicit_url as _elicit_url from mcp.server.lowlevel.helper_types import ReadResourceContents -from mcp.server.lowlevel.server import LifespanResultT -from mcp.server.lowlevel.server import Server as MCPServer +from mcp.server.lowlevel.server import LifespanResultT, Server, request_ctx from mcp.server.lowlevel.server import lifespan as default_lifespan -from mcp.server.session import ServerSession, ServerSessionT +from mcp.server.mcpserver.exceptions import ResourceError +from mcp.server.mcpserver.prompts import Prompt, PromptManager +from mcp.server.mcpserver.resources import FunctionResource, Resource, ResourceManager +from mcp.server.mcpserver.tools import Tool, ToolManager +from mcp.server.mcpserver.utilities.context_injection import find_context_parameter +from mcp.server.mcpserver.utilities.logging import configure_logging, get_logger from mcp.server.sse import SseServerTransport from mcp.server.stdio import stdio_server from mcp.server.streamable_http import EventStore from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from mcp.server.transport_security import TransportSecuritySettings -from mcp.shared.context import LifespanContextT, RequestContext, RequestT -from mcp.types import Annotations, AnyFunction, ContentBlock, GetPromptResult, Icon, ToolAnnotations +from mcp.shared.exceptions import MCPError +from mcp.types import ( + Annotations, + BlobResourceContents, + CallToolRequestParams, + CallToolResult, + CompleteRequestParams, + CompleteResult, + Completion, + ContentBlock, + GetPromptRequestParams, + GetPromptResult, + Icon, + ListPromptsResult, + ListResourcesResult, + ListResourceTemplatesResult, + ListToolsResult, + PaginatedRequestParams, + ReadResourceRequestParams, + ReadResourceResult, + TextContent, + TextResourceContents, + ToolAnnotations, +) from mcp.types import Prompt as MCPPrompt from mcp.types import PromptArgument as MCPPromptArgument from mcp.types import Resource as MCPResource @@ -74,16 +76,18 @@ logger = get_logger(__name__) +_CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) + class Settings(BaseSettings, Generic[LifespanResultT]): - """FastMCP server settings. + """MCPServer settings. - All settings can be configured via environment variables with the prefix FASTMCP_. - For example, FASTMCP_DEBUG=true will set debug=True. + All settings can be configured via environment variables with the prefix MCP_. + For example, MCP_DEBUG=true will set debug=True. """ model_config = SettingsConfigDict( - env_prefix="FASTMCP_", + env_prefix="MCP_", env_file=".env", env_nested_delimiter="__", nested_model_default_partial_update=True, @@ -94,19 +98,6 @@ class Settings(BaseSettings, Generic[LifespanResultT]): debug: bool log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] - # HTTP settings - host: str - port: int - mount_path: str - sse_path: str - message_path: str - streamable_http_path: str - - # StreamableHTTP settings - json_response: bool - stateless_http: bool - """Define if the server should create a new transport per request.""" - # resource settings warn_on_duplicate_resources: bool @@ -116,104 +107,78 @@ class Settings(BaseSettings, Generic[LifespanResultT]): # prompt settings warn_on_duplicate_prompts: bool - # TODO(Marcelo): Investigate if this is used. If it is, it's probably a good idea to remove it. - dependencies: list[str] - """A list of dependencies to install in the server environment.""" - - lifespan: Callable[[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None - """A async context manager that will be called when the server is started.""" + lifespan: Callable[[MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None + """An async context manager that will be called when the server is started.""" auth: AuthSettings | None - # Transport security settings (DNS rebinding protection) - transport_security: TransportSecuritySettings | None - def lifespan_wrapper( - app: FastMCP[LifespanResultT], - lifespan: Callable[[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]], -) -> Callable[[MCPServer[LifespanResultT, Request]], AbstractAsyncContextManager[LifespanResultT]]: + app: MCPServer[LifespanResultT], + lifespan: Callable[[MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]], +) -> Callable[[Server[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]]: @asynccontextmanager - async def wrap( - _: MCPServer[LifespanResultT, Request], - ) -> AsyncIterator[LifespanResultT]: + async def wrap(_: Server[LifespanResultT]) -> AsyncIterator[LifespanResultT]: async with lifespan(app) as context: yield context return wrap -class FastMCP(Generic[LifespanResultT]): - def __init__( # noqa: PLR0913 +class MCPServer(Generic[LifespanResultT]): + def __init__( self, name: str | None = None, + title: str | None = None, + description: str | None = None, instructions: str | None = None, website_url: str | None = None, icons: list[Icon] | None = None, - auth_server_provider: (OAuthAuthorizationServerProvider[Any, Any, Any] | None) = None, + version: str | None = None, + auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None, token_verifier: TokenVerifier | None = None, - event_store: EventStore | None = None, - retry_interval: int | None = None, *, tools: list[Tool] | None = None, debug: bool = False, log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO", - host: str = "127.0.0.1", - port: int = 8000, - mount_path: str = "/", - sse_path: str = "/sse", - message_path: str = "/messages/", - streamable_http_path: str = "/mcp", - json_response: bool = False, - stateless_http: bool = False, warn_on_duplicate_resources: bool = True, warn_on_duplicate_tools: bool = True, warn_on_duplicate_prompts: bool = True, - dependencies: Collection[str] = (), - lifespan: (Callable[[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None) = None, + lifespan: Callable[[MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None = None, auth: AuthSettings | None = None, - transport_security: TransportSecuritySettings | None = None, ): - # Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6) - if transport_security is None and host in ("127.0.0.1", "localhost", "::1"): - transport_security = TransportSecuritySettings( - enable_dns_rebinding_protection=True, - allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"], - allowed_origins=["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"], - ) - self.settings = Settings( debug=debug, log_level=log_level, - host=host, - port=port, - mount_path=mount_path, - sse_path=sse_path, - message_path=message_path, - streamable_http_path=streamable_http_path, - json_response=json_response, - stateless_http=stateless_http, warn_on_duplicate_resources=warn_on_duplicate_resources, warn_on_duplicate_tools=warn_on_duplicate_tools, warn_on_duplicate_prompts=warn_on_duplicate_prompts, - dependencies=list(dependencies), lifespan=lifespan, auth=auth, - transport_security=transport_security, ) - self._mcp_server = MCPServer( - name=name or "FastMCP", + self._tool_manager = ToolManager(tools=tools, warn_on_duplicate_tools=self.settings.warn_on_duplicate_tools) + self._resource_manager = ResourceManager(warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources) + self._prompt_manager = PromptManager(warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts) + self._lowlevel_server = Server( + name=name or "mcp-server", + title=title, + description=description, instructions=instructions, website_url=website_url, icons=icons, - # TODO(Marcelo): It seems there's a type mismatch between the lifespan type from an FastMCP and Server. + version=version, + on_list_tools=self._handle_list_tools, + on_call_tool=self._handle_call_tool, + on_list_resources=self._handle_list_resources, + on_read_resource=self._handle_read_resource, + on_list_resource_templates=self._handle_list_resource_templates, + on_list_prompts=self._handle_list_prompts, + on_get_prompt=self._handle_get_prompt, + # TODO(Marcelo): It seems there's a type mismatch between the lifespan type from an MCPServer and Server. # We need to create a Lifespan type that is a generic on the server type, like Starlette does. lifespan=(lifespan_wrapper(self, self.settings.lifespan) if self.settings.lifespan else default_lifespan), # type: ignore ) - self._tool_manager = ToolManager(tools=tools, warn_on_duplicate_tools=self.settings.warn_on_duplicate_tools) - self._resource_manager = ResourceManager(warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources) - self._prompt_manager = PromptManager(warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts) # Validate auth configuration if self.settings.auth is not None: if auth_server_provider and token_verifier: # pragma: no cover @@ -229,63 +194,91 @@ def __init__( # noqa: PLR0913 # Create token verifier from provider if needed (backwards compatibility) if auth_server_provider and not token_verifier: # pragma: no cover self._token_verifier = ProviderTokenVerifier(auth_server_provider) - self._event_store = event_store - self._retry_interval = retry_interval self._custom_starlette_routes: list[Route] = [] - self.dependencies = self.settings.dependencies - self._session_manager: StreamableHTTPSessionManager | None = None - - # Set up MCP protocol handlers - self._setup_handlers() # Configure logging configure_logging(self.settings.log_level) @property def name(self) -> str: - return self._mcp_server.name + return self._lowlevel_server.name + + @property + def title(self) -> str | None: + return self._lowlevel_server.title + + @property + def description(self) -> str | None: + return self._lowlevel_server.description @property def instructions(self) -> str | None: - return self._mcp_server.instructions + return self._lowlevel_server.instructions @property def website_url(self) -> str | None: - return self._mcp_server.website_url + return self._lowlevel_server.website_url @property def icons(self) -> list[Icon] | None: - return self._mcp_server.icons + return self._lowlevel_server.icons + + @property + def version(self) -> str | None: + return self._lowlevel_server.version @property def session_manager(self) -> StreamableHTTPSessionManager: """Get the StreamableHTTP session manager. This is exposed to enable advanced use cases like mounting multiple - FastMCP servers in a single FastAPI application. + MCPServer instances in a single FastAPI application. Raises: RuntimeError: If called before streamable_http_app() has been called. """ - if self._session_manager is None: # pragma: no cover - raise RuntimeError( - "Session manager can only be accessed after" - "calling streamable_http_app()." - "The session manager is created lazily" - "to avoid unnecessary initialization." - ) - return self._session_manager # pragma: no cover + return self._lowlevel_server.session_manager # pragma: no cover + + @overload + def run(self, transport: Literal["stdio"] = ...) -> None: ... + + @overload + def run( + self, + transport: Literal["sse"], + *, + host: str = ..., + port: int = ..., + sse_path: str = ..., + message_path: str = ..., + transport_security: TransportSecuritySettings | None = ..., + ) -> None: ... + + @overload + def run( + self, + transport: Literal["streamable-http"], + *, + host: str = ..., + port: int = ..., + streamable_http_path: str = ..., + json_response: bool = ..., + stateless_http: bool = ..., + event_store: EventStore | None = ..., + retry_interval: int | None = ..., + transport_security: TransportSecuritySettings | None = ..., + ) -> None: ... def run( self, transport: Literal["stdio", "sse", "streamable-http"] = "stdio", - mount_path: str | None = None, + **kwargs: Any, ) -> None: - """Run the FastMCP server. Note this is a synchronous function. + """Run the MCP server. Note this is a synchronous function. Args: transport: Transport protocol to use ("stdio", "sse", or "streamable-http") - mount_path: Optional mount path for SSE transport + **kwargs: Transport-specific options (see overloads for details) """ TRANSPORTS = Literal["stdio", "sse", "streamable-http"] if transport not in TRANSPORTS.__args__: # type: ignore # pragma: no cover @@ -295,22 +288,87 @@ def run( case "stdio": anyio.run(self.run_stdio_async) case "sse": # pragma: no cover - anyio.run(lambda: self.run_sse_async(mount_path)) + anyio.run(lambda: self.run_sse_async(**kwargs)) case "streamable-http": # pragma: no cover - anyio.run(self.run_streamable_http_async) - - def _setup_handlers(self) -> None: - """Set up core MCP protocol handlers.""" - self._mcp_server.list_tools()(self.list_tools) - # Note: we disable the lowlevel server's input validation. - # FastMCP does ad hoc conversion of incoming data before validating - - # for now we preserve this for backwards compatibility. - self._mcp_server.call_tool(validate_input=False)(self.call_tool) - self._mcp_server.list_resources()(self.list_resources) - self._mcp_server.read_resource()(self.read_resource) - self._mcp_server.list_prompts()(self.list_prompts) - self._mcp_server.get_prompt()(self.get_prompt) - self._mcp_server.list_resource_templates()(self.list_resource_templates) + anyio.run(lambda: self.run_streamable_http_async(**kwargs)) + + async def _handle_list_tools( + self, ctx: ServerRequestContext[LifespanResultT], params: PaginatedRequestParams | None + ) -> ListToolsResult: + return ListToolsResult(tools=await self.list_tools()) + + async def _handle_call_tool( + self, ctx: ServerRequestContext[LifespanResultT], params: CallToolRequestParams + ) -> CallToolResult: + try: + result = await self.call_tool(params.name, params.arguments or {}) + except MCPError: + raise + except Exception as e: + return CallToolResult(content=[TextContent(type="text", text=str(e))], is_error=True) + if isinstance(result, CallToolResult): + return result + if isinstance(result, tuple) and len(result) == 2: + unstructured_content, structured_content = result + return CallToolResult( + content=list(unstructured_content), # type: ignore[arg-type] + structured_content=structured_content, # type: ignore[arg-type] + ) + if isinstance(result, dict): # pragma: no cover + # TODO: this code path is unreachable — convert_result never returns a raw dict. + # The call_tool return type (Sequence[ContentBlock] | dict[str, Any]) is wrong + # and needs to be cleaned up. + return CallToolResult( + content=[TextContent(type="text", text=json.dumps(result, indent=2))], + structured_content=result, + ) + return CallToolResult(content=list(result)) + + async def _handle_list_resources( + self, ctx: ServerRequestContext[LifespanResultT], params: PaginatedRequestParams | None + ) -> ListResourcesResult: + return ListResourcesResult(resources=await self.list_resources()) + + async def _handle_read_resource( + self, ctx: ServerRequestContext[LifespanResultT], params: ReadResourceRequestParams + ) -> ReadResourceResult: + results = await self.read_resource(params.uri) + contents: list[TextResourceContents | BlobResourceContents] = [] + for item in results: + if isinstance(item.content, bytes): + contents.append( + BlobResourceContents( + uri=params.uri, + blob=base64.b64encode(item.content).decode(), + mime_type=item.mime_type or "application/octet-stream", + _meta=item.meta, + ) + ) + else: + contents.append( + TextResourceContents( + uri=params.uri, + text=item.content, + mime_type=item.mime_type or "text/plain", + _meta=item.meta, + ) + ) + return ReadResourceResult(contents=contents) + + async def _handle_list_resource_templates( + self, ctx: ServerRequestContext[LifespanResultT], params: PaginatedRequestParams | None + ) -> ListResourceTemplatesResult: + return ListResourceTemplatesResult(resource_templates=await self.list_resource_templates()) + + async def _handle_list_prompts( + self, ctx: ServerRequestContext[LifespanResultT], params: PaginatedRequestParams | None + ) -> ListPromptsResult: + return ListPromptsResult(prompts=await self.list_prompts()) + + async def _handle_get_prompt( + self, ctx: ServerRequestContext[LifespanResultT], params: GetPromptRequestParams + ) -> GetPromptResult: + return await self.get_prompt(params.name, params.arguments) async def list_tools(self) -> list[MCPTool]: """List all available tools.""" @@ -320,8 +378,8 @@ async def list_tools(self) -> list[MCPTool]: name=info.name, title=info.title, description=info.description, - inputSchema=info.parameters, - outputSchema=info.output_schema, + input_schema=info.parameters, + output_schema=info.output_schema, annotations=info.annotations, icons=info.icons, _meta=info.meta, @@ -329,16 +387,17 @@ async def list_tools(self) -> list[MCPTool]: for info in tools ] - def get_context(self) -> Context[ServerSession, LifespanResultT, Request]: - """ - Returns a Context object. Note that the context will only be valid - during a request; outside a request, most methods will error. + def get_context(self) -> Context[LifespanResultT, Request]: + """Return a Context object. + + Note that the context will only be valid during a request; outside a + request, most methods will error. """ try: - request_context = self._mcp_server.request_context + request_context = request_ctx.get() except LookupError: request_context = None - return Context(request_context=request_context, fastmcp=self) + return Context(request_context=request_context, mcp_server=self) async def call_tool(self, name: str, arguments: dict[str, Any]) -> Sequence[ContentBlock] | dict[str, Any]: """Call a tool by name with arguments.""" @@ -355,9 +414,10 @@ async def list_resources(self) -> list[MCPResource]: name=resource.name or "", title=resource.title, description=resource.description, - mimeType=resource.mime_type, + mime_type=resource.mime_type, icons=resource.icons, annotations=resource.annotations, + _meta=resource.meta, ) for resource in resources ] @@ -366,13 +426,14 @@ async def list_resource_templates(self) -> list[MCPResourceTemplate]: templates = self._resource_manager.list_templates() return [ MCPResourceTemplate( - uriTemplate=template.uri_template, + uri_template=template.uri_template, name=template.name, title=template.title, description=template.description, - mimeType=template.mime_type, + mime_type=template.mime_type, icons=template.icons, annotations=template.annotations, + _meta=template.meta, ) for template in templates ] @@ -381,20 +442,22 @@ async def read_resource(self, uri: AnyUrl | str) -> Iterable[ReadResourceContent """Read a resource by URI.""" context = self.get_context() - resource = await self._resource_manager.get_resource(uri, context=context) - if not resource: # pragma: no cover + try: + resource = await self._resource_manager.get_resource(uri, context=context) + except ValueError: raise ResourceError(f"Unknown resource: {uri}") try: content = await resource.read() - return [ReadResourceContents(content=content, mime_type=resource.mime_type)] - except Exception as e: # pragma: no cover - logger.exception(f"Error reading resource {uri}") - raise ResourceError(str(e)) + return [ReadResourceContents(content=content, mime_type=resource.mime_type, meta=resource.meta)] + except Exception as exc: + logger.exception(f"Error getting resource {uri}") + # If an exception happens when reading the resource, we should not leak the exception to the client. + raise ResourceError(f"Error reading resource {uri}") from exc def add_tool( self, - fn: AnyFunction, + fn: Callable[..., Any], name: str | None = None, title: str | None = None, description: str | None = None, @@ -414,6 +477,8 @@ def add_tool( title: Optional human-readable title for the tool description: Optional description of what the tool does annotations: Optional ToolAnnotations providing additional tool information + icons: Optional list of icons for the tool + meta: Optional metadata dictionary for the tool structured_output: Controls whether the tool's output is structured or unstructured - If None, auto-detects based on the function's return type annotation - If True, creates a structured tool (return type annotation permitting) @@ -450,7 +515,7 @@ def tool( icons: list[Icon] | None = None, meta: dict[str, Any] | None = None, structured_output: bool | None = None, - ) -> Callable[[AnyFunction], AnyFunction]: + ) -> Callable[[_CallableT], _CallableT]: """Decorator to register a tool. Tools can optionally request a Context object by adding a parameter with the @@ -462,25 +527,33 @@ def tool( title: Optional human-readable title for the tool description: Optional description of what the tool does annotations: Optional ToolAnnotations providing additional tool information + icons: Optional list of icons for the tool + meta: Optional metadata dictionary for the tool structured_output: Controls whether the tool's output is structured or unstructured - If None, auto-detects based on the function's return type annotation - If True, creates a structured tool (return type annotation permitting) - If False, unconditionally creates an unstructured tool Example: + ```python @server.tool() def my_tool(x: int) -> str: return str(x) + ``` + ```python @server.tool() - def tool_with_context(x: int, ctx: Context) -> str: - ctx.info(f"Processing {x}") + async def tool_with_context(x: int, ctx: Context) -> str: + await ctx.info(f"Processing {x}") return str(x) + ``` + ```python @server.tool() async def async_tool(x: int, context: Context) -> str: await context.report_progress(50, 100) return str(x) + ``` """ # Check if user passed function directly instead of calling decorator if callable(name): @@ -488,7 +561,7 @@ async def async_tool(x: int, context: Context) -> str: "The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool" ) - def decorator(fn: AnyFunction) -> AnyFunction: + def decorator(fn: _CallableT) -> _CallableT: self.add_tool( fn, name=name, @@ -512,14 +585,33 @@ def completion(self): - context: Optional CompletionContext with previously resolved arguments Example: + ```python @mcp.completion() async def handle_completion(ref, argument, context): if isinstance(ref, ResourceTemplateReference): # Return completions based on ref, argument, and context return Completion(values=["option1", "option2"]) return None + ``` """ - return self._mcp_server.completion() + + def decorator(func: _CallableT) -> _CallableT: + async def handler( + ctx: ServerRequestContext[LifespanResultT], params: CompleteRequestParams + ) -> CompleteResult: + result = await func(params.ref, params.argument, params.context) + return CompleteResult( + completion=result if result is not None else Completion(values=[], total=None, has_more=None), + ) + + # TODO(maxisbey): remove private access — completion needs post-construction + # handler registration, find a better pattern for this + self._lowlevel_server._add_request_handler( # pyright: ignore[reportPrivateUsage] + "completion/complete", handler + ) + return func + + return decorator def add_resource(self, resource: Resource) -> None: """Add a resource to the server. @@ -539,7 +631,8 @@ def resource( mime_type: str | None = None, icons: list[Icon] | None = None, annotations: Annotations | None = None, - ) -> Callable[[AnyFunction], AnyFunction]: + meta: dict[str, Any] | None = None, + ) -> Callable[[_CallableT], _CallableT]: """Decorator to register a function as a resource. The function will be called when the resource is read to generate its content. @@ -557,14 +650,18 @@ def resource( title: Optional human-readable title for the resource description: Optional description of the resource mime_type: Optional MIME type for the resource + icons: Optional list of icons for the resource + annotations: Optional annotations for the resource + meta: Optional metadata dictionary for the resource Example: + ```python @server.resource("resource://my-resource") def get_data() -> str: return "Hello, world!" @server.resource("resource://my-resource") - async get_data() -> str: + async def get_data() -> str: data = await fetch_data() return f"Hello, world! {data}" @@ -576,6 +673,7 @@ def get_weather(city: str) -> str: async def get_weather(city: str) -> str: data = await fetch_weather(city) return f"Weather for {city}: {data}" + ``` """ # Check if user passed function directly instead of calling decorator if callable(uri): @@ -584,7 +682,7 @@ async def get_weather(city: str) -> str: "Did you forget to call it? Use @resource('uri') instead of @resource" ) - def decorator(fn: AnyFunction) -> AnyFunction: + def decorator(fn: _CallableT) -> _CallableT: # Check if this should be a template sig = inspect.signature(fn) has_uri_params = "{" in uri and "}" in uri @@ -615,6 +713,7 @@ def decorator(fn: AnyFunction) -> AnyFunction: mime_type=mime_type, icons=icons, annotations=annotations, + meta=meta, ) else: # Register as regular resource @@ -627,6 +726,7 @@ def decorator(fn: AnyFunction) -> AnyFunction: mime_type=mime_type, icons=icons, annotations=annotations, + meta=meta, ) self.add_resource(resource) return fn @@ -647,15 +747,17 @@ def prompt( title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, - ) -> Callable[[AnyFunction], AnyFunction]: + ) -> Callable[[_CallableT], _CallableT]: """Decorator to register a prompt. Args: name: Optional name for the prompt (defaults to function name) title: Optional human-readable title for the prompt description: Optional description of what the prompt does + icons: Optional list of icons for the prompt Example: + ```python @server.prompt() def analyze_table(table_name: str) -> list[Message]: schema = read_table_schema(table_name) @@ -681,6 +783,7 @@ async def analyze_file(path: str) -> list[Message]: } } ] + ``` """ # Check if user passed function directly instead of calling decorator if callable(name): @@ -689,7 +792,7 @@ async def analyze_file(path: str) -> list[Message]: "Did you forget to call it? Use @prompt() instead of @prompt" ) - def decorator(func: AnyFunction) -> AnyFunction: + def decorator(func: _CallableT) -> _CallableT: prompt = Prompt.from_function(func, name=name, title=title, description=description, icons=icons) self.add_prompt(prompt) return func @@ -703,8 +806,7 @@ def custom_route( name: str | None = None, include_in_schema: bool = True, ): - """ - Decorator to register a custom HTTP route on the FastMCP server. + """Decorator to register a custom HTTP route on the MCP server. Allows adding arbitrary HTTP endpoints outside the standard MCP protocol, which can be useful for OAuth callbacks, health checks, or admin APIs. @@ -723,22 +825,18 @@ def custom_route( include_in_schema: Whether to include in OpenAPI schema, defaults to True Example: + ```python @server.custom_route("/health", methods=["GET"]) async def health_check(request: Request) -> Response: return JSONResponse({"status": "ok"}) + ``` """ def decorator( # pragma: no cover func: Callable[[Request], Awaitable[Response]], ) -> Callable[[Request], Awaitable[Response]]: self._custom_starlette_routes.append( - Route( - path, - endpoint=func, - methods=methods, - name=name, - include_in_schema=include_in_schema, - ) + Route(path, endpoint=func, methods=methods, name=name, include_in_schema=include_in_schema) ) return func @@ -747,106 +845,106 @@ def decorator( # pragma: no cover async def run_stdio_async(self) -> None: """Run the server using stdio transport.""" async with stdio_server() as (read_stream, write_stream): - await self._mcp_server.run( + await self._lowlevel_server.run( read_stream, write_stream, - self._mcp_server.create_initialization_options(), + self._lowlevel_server.create_initialization_options(), ) - async def run_sse_async(self, mount_path: str | None = None) -> None: # pragma: no cover + async def run_sse_async( # pragma: no cover + self, + *, + host: str = "127.0.0.1", + port: int = 8000, + sse_path: str = "/sse", + message_path: str = "/messages/", + transport_security: TransportSecuritySettings | None = None, + ) -> None: """Run the server using SSE transport.""" import uvicorn - starlette_app = self.sse_app(mount_path) + starlette_app = self.sse_app( + sse_path=sse_path, + message_path=message_path, + transport_security=transport_security, + host=host, + ) config = uvicorn.Config( starlette_app, - host=self.settings.host, - port=self.settings.port, + host=host, + port=port, log_level=self.settings.log_level.lower(), ) server = uvicorn.Server(config) await server.serve() - async def run_streamable_http_async(self) -> None: # pragma: no cover + async def run_streamable_http_async( # pragma: no cover + self, + *, + host: str = "127.0.0.1", + port: int = 8000, + streamable_http_path: str = "/mcp", + json_response: bool = False, + stateless_http: bool = False, + event_store: EventStore | None = None, + retry_interval: int | None = None, + transport_security: TransportSecuritySettings | None = None, + ) -> None: """Run the server using StreamableHTTP transport.""" import uvicorn - starlette_app = self.streamable_http_app() + starlette_app = self.streamable_http_app( + streamable_http_path=streamable_http_path, + json_response=json_response, + stateless_http=stateless_http, + event_store=event_store, + retry_interval=retry_interval, + transport_security=transport_security, + host=host, + ) config = uvicorn.Config( starlette_app, - host=self.settings.host, - port=self.settings.port, + host=host, + port=port, log_level=self.settings.log_level.lower(), ) server = uvicorn.Server(config) await server.serve() - def _normalize_path(self, mount_path: str, endpoint: str) -> str: - """ - Combine mount path and endpoint to return a normalized path. - - Args: - mount_path: The mount path (e.g. "/github" or "/") - endpoint: The endpoint path (e.g. "/messages/") - - Returns: - Normalized path (e.g. "/github/messages/") - """ - # Special case: root path - if mount_path == "/": - return endpoint - - # Remove trailing slash from mount path - if mount_path.endswith("/"): - mount_path = mount_path[:-1] - - # Ensure endpoint starts with slash - if not endpoint.startswith("/"): - endpoint = "/" + endpoint - - # Combine paths - return mount_path + endpoint - - def sse_app(self, mount_path: str | None = None) -> Starlette: + def sse_app( + self, + *, + sse_path: str = "/sse", + message_path: str = "/messages/", + transport_security: TransportSecuritySettings | None = None, + host: str = "127.0.0.1", + ) -> Starlette: """Return an instance of the SSE server app.""" - from starlette.middleware import Middleware - from starlette.routing import Mount, Route - - # Update mount_path in settings if provided - if mount_path is not None: - self.settings.mount_path = mount_path - - # Create normalized endpoint considering the mount path - normalized_message_endpoint = self._normalize_path(self.settings.mount_path, self.settings.message_path) - - # Set up auth context and dependencies + # Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6) + if transport_security is None and host in ("127.0.0.1", "localhost", "::1"): + transport_security = TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"], + allowed_origins=["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"], + ) - sse = SseServerTransport( - normalized_message_endpoint, - security_settings=self.settings.transport_security, - ) + sse = SseServerTransport(message_path, security_settings=transport_security) async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no cover # Add client ID from auth context into request context if available - async with sse.connect_sse( - scope, - receive, - send, - ) as streams: - await self._mcp_server.run( - streams[0], - streams[1], - self._mcp_server.create_initialization_options(), + async with sse.connect_sse(scope, receive, send) as streams: + await self._lowlevel_server.run( + streams[0], streams[1], self._lowlevel_server.create_initialization_options() ) return Response() # Create routes routes: list[Route | Mount] = [] middleware: list[Middleware] = [] - required_scopes = [] + required_scopes: list[str] = [] # Set up auth if configured if self.settings.auth: # pragma: no cover @@ -892,34 +990,34 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no # Auth is enabled, wrap the endpoints with RequireAuthMiddleware routes.append( Route( - self.settings.sse_path, + sse_path, endpoint=RequireAuthMiddleware(handle_sse, required_scopes, resource_metadata_url), methods=["GET"], ) ) routes.append( Mount( - self.settings.message_path, + message_path, app=RequireAuthMiddleware(sse.handle_post_message, required_scopes, resource_metadata_url), ) ) - else: # pragma: no cover + else: # Auth is disabled, no need for RequireAuthMiddleware # Since handle_sse is an ASGI app, we need to create a compatible endpoint - async def sse_endpoint(request: Request) -> Response: + async def sse_endpoint(request: Request) -> Response: # pragma: no cover # Convert the Starlette request to ASGI parameters return await handle_sse(request.scope, request.receive, request._send) # type: ignore[reportPrivateUsage] routes.append( Route( - self.settings.sse_path, + sse_path, endpoint=sse_endpoint, methods=["GET"], ) ) routes.append( Mount( - self.settings.message_path, + message_path, app=sse.handle_post_message, ) ) @@ -941,101 +1039,31 @@ async def sse_endpoint(request: Request) -> Response: # Create Starlette app with routes and middleware return Starlette(debug=self.settings.debug, routes=routes, middleware=middleware) - def streamable_http_app(self) -> Starlette: + def streamable_http_app( + self, + *, + streamable_http_path: str = "/mcp", + json_response: bool = False, + stateless_http: bool = False, + event_store: EventStore | None = None, + retry_interval: int | None = None, + transport_security: TransportSecuritySettings | None = None, + host: str = "127.0.0.1", + ) -> Starlette: """Return an instance of the StreamableHTTP server app.""" - from starlette.middleware import Middleware - - # Create session manager on first call (lazy initialization) - if self._session_manager is None: # pragma: no branch - self._session_manager = StreamableHTTPSessionManager( - app=self._mcp_server, - event_store=self._event_store, - retry_interval=self._retry_interval, - json_response=self.settings.json_response, - stateless=self.settings.stateless_http, # Use the stateless setting - security_settings=self.settings.transport_security, - ) - - # Create the ASGI handler - streamable_http_app = StreamableHTTPASGIApp(self._session_manager) - - # Create routes - routes: list[Route | Mount] = [] - middleware: list[Middleware] = [] - required_scopes = [] - - # Set up auth if configured - if self.settings.auth: # pragma: no cover - required_scopes = self.settings.auth.required_scopes or [] - - # Add auth middleware if token verifier is available - if self._token_verifier: - middleware = [ - Middleware( - AuthenticationMiddleware, - backend=BearerAuthBackend(self._token_verifier), - ), - Middleware(AuthContextMiddleware), - ] - - # Add auth endpoints if auth server provider is configured - if self._auth_server_provider: - from mcp.server.auth.routes import create_auth_routes - - routes.extend( - create_auth_routes( - provider=self._auth_server_provider, - issuer_url=self.settings.auth.issuer_url, - service_documentation_url=self.settings.auth.service_documentation_url, - client_registration_options=self.settings.auth.client_registration_options, - revocation_options=self.settings.auth.revocation_options, - ) - ) - - # Set up routes with or without auth - if self._token_verifier: # pragma: no cover - # Determine resource metadata URL - resource_metadata_url = None - if self.settings.auth and self.settings.auth.resource_server_url: - from mcp.server.auth.routes import build_resource_metadata_url - - # Build compliant metadata URL for WWW-Authenticate header - resource_metadata_url = build_resource_metadata_url(self.settings.auth.resource_server_url) - - routes.append( - Route( - self.settings.streamable_http_path, - endpoint=RequireAuthMiddleware(streamable_http_app, required_scopes, resource_metadata_url), - ) - ) - else: - # Auth is disabled, no wrapper needed - routes.append( - Route( - self.settings.streamable_http_path, - endpoint=streamable_http_app, - ) - ) - - # Add protected resource metadata endpoint if configured as RS - if self.settings.auth and self.settings.auth.resource_server_url: # pragma: no cover - from mcp.server.auth.routes import create_protected_resource_routes - - routes.extend( - create_protected_resource_routes( - resource_url=self.settings.auth.resource_server_url, - authorization_servers=[self.settings.auth.issuer_url], - scopes_supported=self.settings.auth.required_scopes, - ) - ) - - routes.extend(self._custom_starlette_routes) - - return Starlette( + return self._lowlevel_server.streamable_http_app( + streamable_http_path=streamable_http_path, + json_response=json_response, + stateless_http=stateless_http, + event_store=event_store, + retry_interval=retry_interval, + transport_security=transport_security, + host=host, + auth=self.settings.auth, + token_verifier=self._token_verifier, + auth_server_provider=self._auth_server_provider, + custom_starlette_routes=self._custom_starlette_routes, debug=self.settings.debug, - routes=routes, - middleware=middleware, - lifespan=lambda app: self.session_manager.run(), ) async def list_prompts(self) -> list[MCPPrompt]: @@ -1077,19 +1105,7 @@ async def get_prompt(self, name: str, arguments: dict[str, Any] | None = None) - raise ValueError(str(e)) -class StreamableHTTPASGIApp: - """ - ASGI application for Streamable HTTP server transport. - """ - - def __init__(self, session_manager: StreamableHTTPSessionManager): - self.session_manager = session_manager - - async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: # pragma: no cover - await self.session_manager.handle_request(scope, receive, send) - - -class Context(BaseModel, Generic[ServerSessionT, LifespanContextT, RequestT]): +class Context(BaseModel, Generic[LifespanContextT, RequestT]): """Context object providing access to MCP capabilities. This provides a cleaner interface to MCP's RequestContext functionality. @@ -1099,18 +1115,18 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT, RequestT]): ```python @server.tool() - def my_tool(x: int, ctx: Context) -> str: + async def my_tool(x: int, ctx: Context) -> str: # Log messages to the client - ctx.info(f"Processing {x}") - ctx.debug("Debug info") - ctx.warning("Warning message") - ctx.error("Error message") + await ctx.info(f"Processing {x}") + await ctx.debug("Debug info") + await ctx.warning("Warning message") + await ctx.error("Error message") # Report progress - ctx.report_progress(50, 100) + await ctx.report_progress(50, 100) # Access resources - data = ctx.read_resource("resource://data") + data = await ctx.read_resource("resource://data") # Get request info request_id = ctx.request_id @@ -1123,31 +1139,30 @@ def my_tool(x: int, ctx: Context) -> str: The context is optional - tools that don't need it can omit the parameter. """ - _request_context: RequestContext[ServerSessionT, LifespanContextT, RequestT] | None - _fastmcp: FastMCP | None + _request_context: ServerRequestContext[LifespanContextT, RequestT] | None + _mcp_server: MCPServer | None def __init__( self, *, - request_context: (RequestContext[ServerSessionT, LifespanContextT, RequestT] | None) = None, - fastmcp: FastMCP | None = None, + request_context: ServerRequestContext[LifespanContextT, RequestT] | None = None, + mcp_server: MCPServer | None = None, + # TODO(Marcelo): We should drop this kwargs parameter. **kwargs: Any, ): super().__init__(**kwargs) self._request_context = request_context - self._fastmcp = fastmcp + self._mcp_server = mcp_server @property - def fastmcp(self) -> FastMCP: - """Access to the FastMCP server.""" - if self._fastmcp is None: # pragma: no cover + def mcp_server(self) -> MCPServer: + """Access to the MCPServer instance.""" + if self._mcp_server is None: # pragma: no cover raise ValueError("Context is not available outside of a request") - return self._fastmcp # pragma: no cover + return self._mcp_server # pragma: no cover @property - def request_context( - self, - ) -> RequestContext[ServerSessionT, LifespanContextT, RequestT]: + def request_context(self) -> ServerRequestContext[LifespanContextT, RequestT]: """Access to the underlying request context.""" if self._request_context is None: # pragma: no cover raise ValueError("Context is not available outside of a request") @@ -1157,11 +1172,11 @@ async def report_progress(self, progress: float, total: float | None = None, mes """Report progress for the current operation. Args: - progress: Current progress value e.g. 24 - total: Optional total value e.g. 100 - message: Optional message e.g. Starting render... + progress: Current progress value (e.g., 24) + total: Optional total value (e.g., 100) + message: Optional message (e.g., "Starting render...") """ - progress_token = self.request_context.meta.progressToken if self.request_context.meta else None + progress_token = self.request_context.meta.get("progress_token") if self.request_context.meta else None if progress_token is None: # pragma: no cover return @@ -1171,6 +1186,7 @@ async def report_progress(self, progress: float, total: float | None = None, mes progress=progress, total=total, message=message, + related_request_id=self.request_id, ) async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]: @@ -1182,8 +1198,8 @@ async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContent Returns: The resource content as either text or bytes """ - assert self._fastmcp is not None, "Context is not available outside of a request" - return await self._fastmcp.read_resource(uri) + assert self._mcp_server is not None, "Context is not available outside of a request" + return await self._mcp_server.read_resource(uri) async def elicit( self, @@ -1194,15 +1210,14 @@ async def elicit( This method can be used to interactively ask for additional information from the client within a tool's execution. The client might display the message to the - user and collect a response according to the provided schema. Or in case a - client is an agent, it might decide how to handle the elicitation -- either by asking + user and collect a response according to the provided schema. If the client + is an agent, it might decide how to handle the elicitation -- either by asking the user or automatically generating a response. Args: - schema: A Pydantic model class defining the expected response structure, according to the specification, - only primive types are allowed. - message: Optional message to present to the user. If not provided, will use - a default message based on the schema + message: Message to present to the user + schema: A Pydantic model class defining the expected response structure. + According to the specification, only primitive types are allowed. Returns: An ElicitationResult containing the action taken and the data if accepted @@ -1236,7 +1251,7 @@ async def elicit_url( The response indicates whether the user consented to navigate to the URL. The actual interaction happens out-of-band. When the elicitation completes, - call `self.session.send_elicit_complete(elicitation_id)` to notify the client. + call `ctx.session.send_elicit_complete(elicitation_id)` to notify the client. Args: message: Human-readable explanation of why the interaction is needed @@ -1260,6 +1275,7 @@ async def log( message: str, *, logger_name: str | None = None, + extra: dict[str, Any] | None = None, ) -> None: """Send a log message to the client. @@ -1267,11 +1283,17 @@ async def log( level: Log level (debug, info, warning, error) message: Log message logger_name: Optional logger name - **extra: Additional structured data to include + extra: Optional dictionary with additional structured data to include """ + + if extra: + log_data = {"message": message, **extra} + else: + log_data = message + await self.request_context.session.send_log_message( level=level, - data=message, + data=log_data, logger=logger_name, related_request_id=self.request_id, ) @@ -1279,9 +1301,7 @@ async def log( @property def client_id(self) -> str | None: """Get the client ID if available.""" - return ( - getattr(self.request_context.meta, "client_id", None) if self.request_context.meta else None - ) # pragma: no cover + return self.request_context.meta.get("client_id") if self.request_context.meta else None # pragma: no cover @property def request_id(self) -> str: @@ -1301,7 +1321,7 @@ async def close_sse_stream(self) -> None: be replayed when the client reconnects with Last-Event-ID. Use this to implement polling behavior during long-running operations - - client will reconnect after the retry interval specified in the priming event. + the client will reconnect after the retry interval specified in the priming event. Note: This is a no-op if not using StreamableHTTP transport with event_store. @@ -1326,18 +1346,20 @@ async def close_standalone_sse_stream(self) -> None: await self._request_context.close_standalone_sse_stream() # Convenience methods for common log levels - async def debug(self, message: str, **extra: Any) -> None: + async def debug(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None: """Send a debug log message.""" - await self.log("debug", message, **extra) + await self.log("debug", message, logger_name=logger_name, extra=extra) - async def info(self, message: str, **extra: Any) -> None: + async def info(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None: """Send an info log message.""" - await self.log("info", message, **extra) + await self.log("info", message, logger_name=logger_name, extra=extra) - async def warning(self, message: str, **extra: Any) -> None: + async def warning( + self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None + ) -> None: """Send a warning log message.""" - await self.log("warning", message, **extra) + await self.log("warning", message, logger_name=logger_name, extra=extra) - async def error(self, message: str, **extra: Any) -> None: + async def error(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None: """Send an error log message.""" - await self.log("error", message, **extra) + await self.log("error", message, logger_name=logger_name, extra=extra) diff --git a/src/mcp/server/fastmcp/tools/__init__.py b/src/mcp/server/mcpserver/tools/__init__.py similarity index 100% rename from src/mcp/server/fastmcp/tools/__init__.py rename to src/mcp/server/mcpserver/tools/__init__.py diff --git a/src/mcp/server/fastmcp/tools/base.py b/src/mcp/server/mcpserver/tools/base.py similarity index 83% rename from src/mcp/server/fastmcp/tools/base.py rename to src/mcp/server/mcpserver/tools/base.py index cf89fc8aa..f6bfadbc4 100644 --- a/src/mcp/server/fastmcp/tools/base.py +++ b/src/mcp/server/mcpserver/tools/base.py @@ -1,4 +1,4 @@ -from __future__ import annotations as _annotations +from __future__ import annotations import functools import inspect @@ -8,16 +8,16 @@ from pydantic import BaseModel, Field -from mcp.server.fastmcp.exceptions import ToolError -from mcp.server.fastmcp.utilities.context_injection import find_context_parameter -from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata +from mcp.server.mcpserver.exceptions import ToolError +from mcp.server.mcpserver.utilities.context_injection import find_context_parameter +from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata +from mcp.shared.exceptions import UrlElicitationRequiredError from mcp.shared.tool_name_validation import validate_and_warn_tool_name from mcp.types import Icon, ToolAnnotations if TYPE_CHECKING: - from mcp.server.fastmcp.server import Context - from mcp.server.session import ServerSessionT - from mcp.shared.context import LifespanContextT, RequestT + from mcp.server.context import LifespanContextT, RequestT + from mcp.server.mcpserver.server import Context class Tool(BaseModel): @@ -92,7 +92,7 @@ def from_function( async def run( self, arguments: dict[str, Any], - context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None, + context: Context[LifespanContextT, RequestT] | None = None, convert_result: bool = False, ) -> Any: """Run the tool with arguments.""" @@ -108,12 +108,16 @@ async def run( result = self.fn_metadata.convert_result(result) return result + except UrlElicitationRequiredError: + # Re-raise UrlElicitationRequiredError so it can be properly handled + # as an MCP error response with code -32042 + raise except Exception as e: raise ToolError(f"Error executing tool {self.name}: {e}") from e def _is_async_callable(obj: Any) -> bool: - while isinstance(obj, functools.partial): # pragma: no cover + while isinstance(obj, functools.partial): # pragma: lax no cover obj = obj.func return inspect.iscoroutinefunction(obj) or ( diff --git a/src/mcp/server/fastmcp/tools/tool_manager.py b/src/mcp/server/mcpserver/tools/tool_manager.py similarity index 83% rename from src/mcp/server/fastmcp/tools/tool_manager.py rename to src/mcp/server/mcpserver/tools/tool_manager.py index 095753de6..c6f8384bd 100644 --- a/src/mcp/server/fastmcp/tools/tool_manager.py +++ b/src/mcp/server/mcpserver/tools/tool_manager.py @@ -1,23 +1,22 @@ -from __future__ import annotations as _annotations +from __future__ import annotations from collections.abc import Callable from typing import TYPE_CHECKING, Any -from mcp.server.fastmcp.exceptions import ToolError -from mcp.server.fastmcp.tools.base import Tool -from mcp.server.fastmcp.utilities.logging import get_logger -from mcp.shared.context import LifespanContextT, RequestT +from mcp.server.mcpserver.exceptions import ToolError +from mcp.server.mcpserver.tools.base import Tool +from mcp.server.mcpserver.utilities.logging import get_logger from mcp.types import Icon, ToolAnnotations if TYPE_CHECKING: - from mcp.server.fastmcp.server import Context - from mcp.server.session import ServerSessionT + from mcp.server.context import LifespanContextT, RequestT + from mcp.server.mcpserver.server import Context logger = get_logger(__name__) class ToolManager: - """Manages FastMCP tools.""" + """Manages MCPServer tools.""" def __init__( self, @@ -82,7 +81,7 @@ async def call_tool( self, name: str, arguments: dict[str, Any], - context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None, + context: Context[LifespanContextT, RequestT] | None = None, convert_result: bool = False, ) -> Any: """Call a tool by name with arguments.""" diff --git a/src/mcp/server/mcpserver/utilities/__init__.py b/src/mcp/server/mcpserver/utilities/__init__.py new file mode 100644 index 000000000..b9d00d54c --- /dev/null +++ b/src/mcp/server/mcpserver/utilities/__init__.py @@ -0,0 +1 @@ +"""MCPServer utility modules.""" diff --git a/src/mcp/server/fastmcp/utilities/context_injection.py b/src/mcp/server/mcpserver/utilities/context_injection.py similarity index 92% rename from src/mcp/server/fastmcp/utilities/context_injection.py rename to src/mcp/server/mcpserver/utilities/context_injection.py index 66d0cbaa0..9cba83e86 100644 --- a/src/mcp/server/fastmcp/utilities/context_injection.py +++ b/src/mcp/server/mcpserver/utilities/context_injection.py @@ -1,4 +1,4 @@ -"""Context injection utilities for FastMCP.""" +"""Context injection utilities for MCPServer.""" from __future__ import annotations @@ -20,12 +20,12 @@ def find_context_parameter(fn: Callable[..., Any]) -> str | None: Returns: The name of the context parameter, or None if not found """ - from mcp.server.fastmcp.server import Context + from mcp.server.mcpserver.server import Context # Get type hints to properly resolve string annotations try: hints = typing.get_type_hints(fn) - except Exception: + except Exception: # pragma: lax no cover # If we can't resolve type hints, we can't find the context parameter return None diff --git a/src/mcp/server/fastmcp/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py similarity index 91% rename from src/mcp/server/fastmcp/utilities/func_metadata.py rename to src/mcp/server/mcpserver/utilities/func_metadata.py index fa443d2fc..062b47d0f 100644 --- a/src/mcp/server/fastmcp/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -1,3 +1,4 @@ +import functools import inspect import json from collections.abc import Awaitable, Callable, Sequence @@ -5,15 +6,10 @@ from types import GenericAlias from typing import Annotated, Any, cast, get_args, get_origin, get_type_hints +import anyio +import anyio.to_thread import pydantic_core -from pydantic import ( - BaseModel, - ConfigDict, - Field, - RootModel, - WithJsonSchema, - create_model, -) +from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, create_model from pydantic.fields import FieldInfo from pydantic.json_schema import GenerateJsonSchema, JsonSchemaWarningKind from typing_extensions import is_typeddict @@ -25,9 +21,9 @@ is_union_origin, ) -from mcp.server.fastmcp.exceptions import InvalidSignature -from mcp.server.fastmcp.utilities.logging import get_logger -from mcp.server.fastmcp.utilities.types import Audio, Image +from mcp.server.mcpserver.exceptions import InvalidSignature +from mcp.server.mcpserver.utilities.logging import get_logger +from mcp.server.mcpserver.utilities.types import Audio, Image from mcp.types import CallToolResult, ContentBlock, TextContent logger = get_logger(__name__) @@ -50,7 +46,7 @@ class ArgModelBase(BaseModel): def model_dump_one_level(self) -> dict[str, Any]: """Return a dict of the model's fields, one level deep. - That is, sub-models etc are not dumped - they are kept as pydantic models. + That is, sub-models etc are not dumped - they are kept as Pydantic models. """ kwargs: dict[str, Any] = {} for field_name, field_info in self.__class__.model_fields.items(): @@ -60,9 +56,7 @@ def model_dump_one_level(self) -> dict[str, Any]: kwargs[output_name] = value return kwargs - model_config = ConfigDict( - arbitrary_types_allowed=True, - ) + model_config = ConfigDict(arbitrary_types_allowed=True) class FuncMetadata(BaseModel): @@ -92,12 +86,10 @@ async def call_fn_with_arg_validation( if fn_is_async: return await fn(**arguments_parsed_dict) else: - return fn(**arguments_parsed_dict) + return await anyio.to_thread.run_sync(functools.partial(fn, **arguments_parsed_dict)) def convert_result(self, result: Any) -> Any: - """ - Convert the result of a function call to the appropriate format for - the lowlevel server tool call handler: + """Convert a function call result to the format for the lowlevel tool call handler. - If output_model is None, return the unstructured content directly. - If output_model is not None, convert the result to structured output format @@ -105,15 +97,15 @@ def convert_result(self, result: Any) -> Any: Note: we return unstructured content here **even though the lowlevel server tool call handler provides generic backwards compatibility serialization of - structured content**. This is for FastMCP backwards compatibility: we need to - retain FastMCP's ad hoc conversion logic for constructing unstructured output + structured content**. This is for MCPServer backwards compatibility: we need to + retain MCPServer's ad hoc conversion logic for constructing unstructured output from function return values, whereas the lowlevel server simply serializes the structured output. """ if isinstance(result, CallToolResult): if self.output_schema is not None: assert self.output_model is not None, "Output model must be set if output schema is defined" - self.output_model.model_validate(result.structuredContent) + self.output_model.model_validate(result.structured_content) return result unstructured_content = _convert_to_content(result) @@ -133,11 +125,11 @@ def convert_result(self, result: Any) -> Any: def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]: """Pre-parse data from JSON. - Return a dict with same keys as input but with values parsed from JSON + Return a dict with the same keys as input but with values parsed from JSON if appropriate. This is to handle cases like `["a", "b", "c"]` being passed in as JSON inside - a string rather than an actual list. Claude desktop is prone to this - in fact + a string rather than an actual list. Claude Desktop is prone to this - in fact it seems incapable of NOT doing this. For sub-models, it tends to pass dicts (JSON objects) as JSON strings, which can be pre-parsed here. """ @@ -180,8 +172,7 @@ def func_metadata( skip_names: Sequence[str] = (), structured_output: bool | None = None, ) -> FuncMetadata: - """Given a function, return metadata including a pydantic model representing its - signature. + """Given a function, return metadata including a Pydantic model representing its signature. The use case for this is ``` @@ -190,11 +181,11 @@ def func_metadata( return func(**validated_args.model_dump_one_level()) ``` - **critically** it also provides pre-parse helper to attempt to parse things from + **critically** it also provides a pre-parse helper to attempt to parse things from JSON. Args: - func: The function to convert to a pydantic model + func: The function to convert to a Pydantic model skip_names: A list of parameter names to skip. These will not be included in the model. structured_output: Controls whether the tool's output is structured or unstructured @@ -202,8 +193,8 @@ def func_metadata( - If True, creates a structured tool (return type annotation permitting) - If False, unconditionally creates an unstructured tool - If structured, creates a Pydantic model for the function's result based on its annotation. - Supports various return types: + If structured, creates a Pydantic model for the function's result based on its annotation. + Supports various return types: - BaseModel subclasses (used directly) - Primitive types (str, int, float, bool, bytes, None) - wrapped in a model with a 'result' field @@ -213,14 +204,14 @@ def func_metadata( Returns: A FuncMetadata object containing: - - arg_model: A pydantic model representing the function's arguments - - output_model: A pydantic model for the return type if output is structured - - output_conversion: Records how function output should be converted before returning. + - arg_model: A Pydantic model representing the function's arguments + - output_model: A Pydantic model for the return type if the output is structured + - wrap_output: Whether the function result needs to be wrapped in `{"result": ...}` for structured output. """ try: sig = inspect.signature(func, eval_str=True) except NameError as e: # pragma: no cover - # This raise could perhaps be skipped, and we (FastMCP) just call + # This raise could perhaps be skipped, and we (MCPServer) just call # model_rebuild right before using it 🤷 raise InvalidSignature(f"Unable to evaluate type annotations for callable {func.__name__!r}") from e params = sig.parameters @@ -303,7 +294,7 @@ def func_metadata( ] # pragma: no cover else: # We only had `Annotated[CallToolResult, ReturnType]`, treat the original annotation - # as beging `ReturnType`: + # as being `ReturnType`: original_annotation = return_type_expr else: return FuncMetadata(arg_model=arguments_model) @@ -362,7 +353,7 @@ def _try_create_model_and_schema( if origin is dict: args = get_args(type_expr) if len(args) == 2 and args[0] is str: - # TODO: should we use the original annotation? We are loosing any potential `Annotated` + # TODO: should we use the original annotation? We are losing any potential `Annotated` # metadata for Pydantic here: model = _create_dict_model(func_name, type_expr) else: @@ -485,6 +476,8 @@ def _create_wrapped_model(func_name: str, annotation: Any) -> type[BaseModel]: def _create_dict_model(func_name: str, dict_annotation: Any) -> type[BaseModel]: """Create a RootModel for dict[str, T] types.""" + # TODO(Marcelo): We should not rely on RootModel for this. + from pydantic import RootModel # noqa: TID251 class DictModel(RootModel[dict_annotation]): pass @@ -496,13 +489,10 @@ class DictModel(RootModel[dict_annotation]): return DictModel -def _convert_to_content( - result: Any, -) -> Sequence[ContentBlock]: - """ - Convert a result to a sequence of content objects. +def _convert_to_content(result: Any) -> Sequence[ContentBlock]: + """Convert a result to a sequence of content objects. - Note: This conversion logic comes from previous versions of FastMCP and is being + Note: This conversion logic comes from previous versions of MCPServer and is being retained for purposes of backwards compatibility. It produces different unstructured output than the lowlevel server tool call handler, which just serializes structured content verbatim. diff --git a/src/mcp/server/fastmcp/utilities/logging.py b/src/mcp/server/mcpserver/utilities/logging.py similarity index 65% rename from src/mcp/server/fastmcp/utilities/logging.py rename to src/mcp/server/mcpserver/utilities/logging.py index 4b47d3b88..04ca38853 100644 --- a/src/mcp/server/fastmcp/utilities/logging.py +++ b/src/mcp/server/mcpserver/utilities/logging.py @@ -1,17 +1,17 @@ -"""Logging utilities for FastMCP.""" +"""Logging utilities for MCPServer.""" import logging from typing import Literal def get_logger(name: str) -> logging.Logger: - """Get a logger nested under MCPnamespace. + """Get a logger nested under MCP namespace. Args: - name: the name of the logger, which will be prefixed with 'FastMCP.' + name: The name of the logger. Returns: - a configured logger instance + A configured logger instance. """ return logging.getLogger(name) @@ -22,10 +22,10 @@ def configure_logging( """Configure logging for MCP. Args: - level: the log level to use + level: The log level to use. """ handlers: list[logging.Handler] = [] - try: # pragma: no cover + try: from rich.console import Console from rich.logging import RichHandler @@ -36,8 +36,4 @@ def configure_logging( if not handlers: # pragma: no cover handlers.append(logging.StreamHandler()) - logging.basicConfig( - level=level, - format="%(message)s", - handlers=handlers, - ) + logging.basicConfig(level=level, format="%(message)s", handlers=handlers) diff --git a/src/mcp/server/fastmcp/utilities/types.py b/src/mcp/server/mcpserver/utilities/types.py similarity index 94% rename from src/mcp/server/fastmcp/utilities/types.py rename to src/mcp/server/mcpserver/utilities/types.py index d6928ca3f..f092b245a 100644 --- a/src/mcp/server/fastmcp/utilities/types.py +++ b/src/mcp/server/mcpserver/utilities/types.py @@ -1,4 +1,4 @@ -"""Common types used across FastMCP.""" +"""Common types used across MCPServer.""" import base64 from pathlib import Path @@ -51,7 +51,7 @@ def to_image_content(self) -> ImageContent: else: # pragma: no cover raise ValueError("No image data available") - return ImageContent(type="image", data=data, mimeType=self._mime_type) + return ImageContent(type="image", data=data, mime_type=self._mime_type) class Audio: @@ -98,4 +98,4 @@ def to_audio_content(self) -> AudioContent: else: # pragma: no cover raise ValueError("No audio data available") - return AudioContent(type="audio", data=data, mimeType=self._mime_type) + return AudioContent(type="audio", data=data, mime_type=self._mime_type) diff --git a/src/mcp/server/models.py b/src/mcp/server/models.py index ddf716cb9..3861f42a7 100644 --- a/src/mcp/server/models.py +++ b/src/mcp/server/models.py @@ -1,19 +1,17 @@ -""" -This module provides simpler types to use with the server for managing prompts +"""This module provides simplified types to use with the server for managing prompts and tools. """ from pydantic import BaseModel -from mcp.types import ( - Icon, - ServerCapabilities, -) +from mcp.types import Icon, ServerCapabilities class InitializationOptions(BaseModel): server_name: str server_version: str + title: str | None = None + description: str | None = None capabilities: ServerCapabilities instructions: str | None = None website_url: str | None = None diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index 8f0baa3e9..759d2131a 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -1,5 +1,4 @@ -""" -ServerSession Module +"""ServerSession Module This module provides the ServerSession class, which manages communication between the server and client in the MCP (Model Context Protocol) framework. It is most commonly @@ -7,30 +6,22 @@ Common usage pattern: ``` - server = Server(name) - - @server.call_tool() - async def handle_tool_call(ctx: RequestContext, arguments: dict[str, Any]) -> Any: + async def handle_call_tool(ctx: RequestContext, params: CallToolRequestParams) -> CallToolResult: # Check client capabilities before proceeding if ctx.session.check_client_capability( types.ClientCapabilities(experimental={"advanced_tools": dict()}) ): - # Perform advanced tool operations - result = await perform_advanced_tool_operation(arguments) + result = await perform_advanced_tool_operation(params.arguments) else: - # Fall back to basic tool operations - result = await perform_basic_tool_operation(arguments) - + result = await perform_basic_tool_operation(params.arguments) return result - @server.list_prompts() - async def handle_list_prompts(ctx: RequestContext) -> list[types.Prompt]: - # Access session for any necessary checks or operations + async def handle_list_prompts(ctx: RequestContext, params) -> ListPromptsResult: if ctx.session.client_params: - # Customize prompts based on client initialization parameters - return generate_custom_prompts(ctx.session.client_params) - else: - return default_prompts + return ListPromptsResult(prompts=generate_custom_prompts(ctx.session.client_params)) + return ListPromptsResult(prompts=default_prompts) + + server = Server(name, on_call_tool=handle_call_tool, on_list_prompts=handle_list_prompts) ``` The ServerSession class is typically used internally by the Server class and should not @@ -43,12 +34,13 @@ async def handle_list_prompts(ctx: RequestContext) -> list[types.Prompt]: import anyio import anyio.lowlevel from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from pydantic import AnyUrl +from pydantic import AnyUrl, TypeAdapter -import mcp.types as types +from mcp import types from mcp.server.experimental.session_features import ExperimentalServerSessionFeatures from mcp.server.models import InitializationOptions from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages +from mcp.shared.exceptions import StatelessModeNotSupported from mcp.shared.experimental.tasks.capabilities import check_tasks_capability from mcp.shared.experimental.tasks.helpers import RELATED_TASK_METADATA_KEY from mcp.shared.message import ServerMessageMetadata, SessionMessage @@ -92,7 +84,8 @@ def __init__( init_options: InitializationOptions, stateless: bool = False, ) -> None: - super().__init__(read_stream, write_stream, types.ClientRequest, types.ClientNotification) + super().__init__(read_stream, write_stream) + self._stateless = stateless self._initialization_state = ( InitializationState.Initialized if stateless else InitializationState.NotInitialized ) @@ -103,9 +96,17 @@ def __init__( ](0) self._exit_stack.push_async_callback(lambda: self._incoming_message_stream_reader.aclose()) + @property + def _receive_request_adapter(self) -> TypeAdapter[types.ClientRequest]: + return types.client_request_adapter + + @property + def _receive_notification_adapter(self) -> TypeAdapter[types.ClientNotification]: + return types.client_notification_adapter + @property def client_params(self) -> types.InitializeRequestParams | None: - return self._client_params # pragma: no cover + return self._client_params @property def experimental(self) -> ExperimentalServerSessionFeatures: @@ -117,20 +118,20 @@ def experimental(self) -> ExperimentalServerSessionFeatures: self._experimental_features = ExperimentalServerSessionFeatures(self) return self._experimental_features - def check_client_capability(self, capability: types.ClientCapabilities) -> bool: # pragma: no cover + def check_client_capability(self, capability: types.ClientCapabilities) -> bool: """Check if the client supports a specific capability.""" - if self._client_params is None: + if self._client_params is None: # pragma: lax no cover return False client_caps = self._client_params.capabilities - if capability.roots is not None: + if capability.roots is not None: # pragma: lax no cover if client_caps.roots is None: return False - if capability.roots.listChanged and not client_caps.roots.listChanged: + if capability.roots.list_changed and not client_caps.roots.list_changed: return False - if capability.sampling is not None: + if capability.sampling is not None: # pragma: lax no cover if client_caps.sampling is None: return False if capability.sampling.context is not None and client_caps.sampling.context is None: @@ -138,17 +139,17 @@ def check_client_capability(self, capability: types.ClientCapabilities) -> bool: if capability.sampling.tools is not None and client_caps.sampling.tools is None: return False - if capability.elicitation is not None and client_caps.elicitation is None: + if capability.elicitation is not None and client_caps.elicitation is None: # pragma: lax no cover return False - if capability.experimental is not None: + if capability.experimental is not None: # pragma: lax no cover if client_caps.experimental is None: return False for exp_key, exp_value in capability.experimental.items(): if exp_key not in client_caps.experimental or client_caps.experimental[exp_key] != exp_value: return False - if capability.tasks is not None: + if capability.tasks is not None: # pragma: lax no cover if client_caps.tasks is None: return False if not check_tasks_capability(capability.tasks, client_caps.tasks): @@ -161,27 +162,27 @@ async def _receive_loop(self) -> None: await super()._receive_loop() async def _received_request(self, responder: RequestResponder[types.ClientRequest, types.ServerResult]): - match responder.request.root: + match responder.request: case types.InitializeRequest(params=params): - requested_version = params.protocolVersion + requested_version = params.protocol_version self._initialization_state = InitializationState.Initializing self._client_params = params with responder: await responder.respond( - types.ServerResult( - types.InitializeResult( - protocolVersion=requested_version - if requested_version in SUPPORTED_PROTOCOL_VERSIONS - else types.LATEST_PROTOCOL_VERSION, - capabilities=self._init_options.capabilities, - serverInfo=types.Implementation( - name=self._init_options.server_name, - version=self._init_options.server_version, - websiteUrl=self._init_options.website_url, - icons=self._init_options.icons, - ), - instructions=self._init_options.instructions, - ) + types.InitializeResult( + protocol_version=requested_version + if requested_version in SUPPORTED_PROTOCOL_VERSIONS + else types.LATEST_PROTOCOL_VERSION, + capabilities=self._init_options.capabilities, + server_info=types.Implementation( + name=self._init_options.server_name, + title=self._init_options.title, + description=self._init_options.description, + version=self._init_options.server_version, + website_url=self._init_options.website_url, + icons=self._init_options.icons, + ), + instructions=self._init_options.instructions, ) ) self._initialization_state = InitializationState.Initialized @@ -195,7 +196,7 @@ async def _received_request(self, responder: RequestResponder[types.ClientReques async def _received_notification(self, notification: types.ClientNotification) -> None: # Need this to avoid ASYNC910 await anyio.lowlevel.checkpoint() - match notification.root: + match notification: case types.InitializedNotification(): self._initialization_state = InitializationState.Initialized case _: @@ -211,25 +212,21 @@ async def send_log_message( ) -> None: """Send a log message notification.""" await self.send_notification( - types.ServerNotification( - types.LoggingMessageNotification( - params=types.LoggingMessageNotificationParams( - level=level, - data=data, - logger=logger, - ), - ) + types.LoggingMessageNotification( + params=types.LoggingMessageNotificationParams( + level=level, + data=data, + logger=logger, + ), ), related_request_id, ) - async def send_resource_updated(self, uri: AnyUrl) -> None: # pragma: no cover + async def send_resource_updated(self, uri: str | AnyUrl) -> None: # pragma: no cover """Send a resource updated notification.""" await self.send_notification( - types.ServerNotification( - types.ResourceUpdatedNotification( - params=types.ResourceUpdatedNotificationParams(uri=uri), - ) + types.ResourceUpdatedNotification( + params=types.ResourceUpdatedNotificationParams(uri=str(uri)), ) ) @@ -309,28 +306,29 @@ async def create_message( The sampling result from the client. Raises: - McpError: If tools are provided but client doesn't support them. + MCPError: If tools are provided but client doesn't support them. ValueError: If tool_use or tool_result message structure is invalid. + StatelessModeNotSupported: If called in stateless HTTP mode. """ + if self._stateless: + raise StatelessModeNotSupported(method="sampling") client_caps = self._client_params.capabilities if self._client_params else None validate_sampling_tools(client_caps, tools, tool_choice) validate_tool_use_result_messages(messages) - request = types.ServerRequest( - types.CreateMessageRequest( - params=types.CreateMessageRequestParams( - messages=messages, - systemPrompt=system_prompt, - includeContext=include_context, - temperature=temperature, - maxTokens=max_tokens, - stopSequences=stop_sequences, - metadata=metadata, - modelPreferences=model_preferences, - tools=tools, - toolChoice=tool_choice, - ), - ) + request = types.CreateMessageRequest( + params=types.CreateMessageRequestParams( + messages=messages, + system_prompt=system_prompt, + include_context=include_context, + temperature=temperature, + max_tokens=max_tokens, + stop_sequences=stop_sequences, + metadata=metadata, + model_preferences=model_preferences, + tools=tools, + tool_choice=tool_choice, + ), ) metadata_obj = ServerMessageMetadata(related_request_id=related_request_id) @@ -349,57 +347,62 @@ async def create_message( async def list_roots(self) -> types.ListRootsResult: """Send a roots/list request.""" + if self._stateless: + raise StatelessModeNotSupported(method="list_roots") return await self.send_request( - types.ServerRequest(types.ListRootsRequest()), + types.ListRootsRequest(), types.ListRootsResult, ) async def elicit( self, message: str, - requestedSchema: types.ElicitRequestedSchema, + requested_schema: types.ElicitRequestedSchema, related_request_id: types.RequestId | None = None, ) -> types.ElicitResult: """Send a form mode elicitation/create request. Args: - message: The message to present to the user - requestedSchema: Schema defining the expected response structure - related_request_id: Optional ID of the request that triggered this elicitation + message: The message to present to the user. + requested_schema: Schema defining the expected response structure. + related_request_id: Optional ID of the request that triggered this elicitation. Returns: - The client's response + The client's response. Note: This method is deprecated in favor of elicit_form(). It remains for backward compatibility but new code should use elicit_form(). """ - return await self.elicit_form(message, requestedSchema, related_request_id) + return await self.elicit_form(message, requested_schema, related_request_id) async def elicit_form( self, message: str, - requestedSchema: types.ElicitRequestedSchema, + requested_schema: types.ElicitRequestedSchema, related_request_id: types.RequestId | None = None, ) -> types.ElicitResult: """Send a form mode elicitation/create request. Args: - message: The message to present to the user - requestedSchema: Schema defining the expected response structure - related_request_id: Optional ID of the request that triggered this elicitation + message: The message to present to the user. + requested_schema: Schema defining the expected response structure. + related_request_id: Optional ID of the request that triggered this elicitation. Returns: - The client's response with form data + The client's response with form data. + + Raises: + StatelessModeNotSupported: If called in stateless HTTP mode. """ + if self._stateless: + raise StatelessModeNotSupported(method="elicitation") return await self.send_request( - types.ServerRequest( - types.ElicitRequest( - params=types.ElicitRequestFormParams( - message=message, - requestedSchema=requestedSchema, - ), - ) + types.ElicitRequest( + params=types.ElicitRequestFormParams( + message=message, + requested_schema=requested_schema, + ), ), types.ElicitResult, metadata=ServerMessageMetadata(related_request_id=related_request_id), @@ -418,23 +421,26 @@ async def elicit_url( like OAuth flows, credential collection, or payment processing. Args: - message: Human-readable explanation of why the interaction is needed - url: The URL the user should navigate to - elicitation_id: Unique identifier for tracking this elicitation - related_request_id: Optional ID of the request that triggered this elicitation + message: Human-readable explanation of why the interaction is needed. + url: The URL the user should navigate to. + elicitation_id: Unique identifier for tracking this elicitation. + related_request_id: Optional ID of the request that triggered this elicitation. Returns: - The client's response indicating acceptance, decline, or cancellation + The client's response indicating acceptance, decline, or cancellation. + + Raises: + StatelessModeNotSupported: If called in stateless HTTP mode. """ + if self._stateless: + raise StatelessModeNotSupported(method="elicitation") return await self.send_request( - types.ServerRequest( - types.ElicitRequest( - params=types.ElicitRequestURLParams( - message=message, - url=url, - elicitationId=elicitation_id, - ), - ) + types.ElicitRequest( + params=types.ElicitRequestURLParams( + message=message, + url=url, + elicitation_id=elicitation_id, + ), ), types.ElicitResult, metadata=ServerMessageMetadata(related_request_id=related_request_id), @@ -443,7 +449,7 @@ async def elicit_url( async def send_ping(self) -> types.EmptyResult: # pragma: no cover """Send a ping request.""" return await self.send_request( - types.ServerRequest(types.PingRequest()), + types.PingRequest(), types.EmptyResult, ) @@ -457,30 +463,28 @@ async def send_progress_notification( ) -> None: """Send a progress notification.""" await self.send_notification( - types.ServerNotification( - types.ProgressNotification( - params=types.ProgressNotificationParams( - progressToken=progress_token, - progress=progress, - total=total, - message=message, - ), - ) + types.ProgressNotification( + params=types.ProgressNotificationParams( + progress_token=progress_token, + progress=progress, + total=total, + message=message, + ), ), related_request_id, ) async def send_resource_list_changed(self) -> None: # pragma: no cover """Send a resource list changed notification.""" - await self.send_notification(types.ServerNotification(types.ResourceListChangedNotification())) + await self.send_notification(types.ResourceListChangedNotification()) async def send_tool_list_changed(self) -> None: # pragma: no cover """Send a tool list changed notification.""" - await self.send_notification(types.ServerNotification(types.ToolListChangedNotification())) + await self.send_notification(types.ToolListChangedNotification()) async def send_prompt_list_changed(self) -> None: # pragma: no cover """Send a prompt list changed notification.""" - await self.send_notification(types.ServerNotification(types.PromptListChangedNotification())) + await self.send_notification(types.PromptListChangedNotification()) async def send_elicit_complete( self, @@ -495,13 +499,11 @@ async def send_elicit_complete( Args: elicitation_id: The unique identifier of the completed elicitation - related_request_id: Optional ID of the request that triggered this + related_request_id: Optional ID of the request that triggered this notification """ await self.send_notification( - types.ServerNotification( - types.ElicitCompleteNotification( - params=types.ElicitCompleteNotificationParams(elicitationId=elicitation_id) - ) + types.ElicitCompleteNotification( + params=types.ElicitCompleteNotificationParams(elicitation_id=elicitation_id) ), related_request_id, ) @@ -509,7 +511,7 @@ async def send_elicit_complete( def _build_elicit_form_request( self, message: str, - requestedSchema: types.ElicitRequestedSchema, + requested_schema: types.ElicitRequestedSchema, related_task_id: str | None = None, task: types.TaskMetadata | None = None, ) -> types.JSONRPCRequest: @@ -517,7 +519,7 @@ def _build_elicit_form_request( Args: message: The message to present to the user - requestedSchema: Schema defining the expected response structure + requested_schema: Schema defining the expected response structure related_task_id: If provided, adds io.modelcontextprotocol/related-task metadata task: If provided, makes this a task-augmented request @@ -526,7 +528,7 @@ def _build_elicit_form_request( """ params = types.ElicitRequestFormParams( message=message, - requestedSchema=requestedSchema, + requested_schema=requested_schema, task=task, ) params_data = params.model_dump(by_alias=True, mode="json", exclude_none=True) @@ -534,10 +536,10 @@ def _build_elicit_form_request( # Add related-task metadata if associated with a parent task if related_task_id is not None: # Defensive: model_dump() never includes _meta, but guard against future changes - if "_meta" not in params_data: # pragma: no cover + if "_meta" not in params_data: # pragma: no branch params_data["_meta"] = {} params_data["_meta"][RELATED_TASK_METADATA_KEY] = types.RelatedTaskMetadata( - taskId=related_task_id + task_id=related_task_id ).model_dump(by_alias=True) request_id = f"task-{related_task_id}-{id(params)}" if related_task_id else self._request_id @@ -572,17 +574,17 @@ def _build_elicit_url_request( params = types.ElicitRequestURLParams( message=message, url=url, - elicitationId=elicitation_id, + elicitation_id=elicitation_id, ) params_data = params.model_dump(by_alias=True, mode="json", exclude_none=True) # Add related-task metadata if associated with a parent task if related_task_id is not None: # Defensive: model_dump() never includes _meta, but guard against future changes - if "_meta" not in params_data: # pragma: no cover + if "_meta" not in params_data: # pragma: no branch params_data["_meta"] = {} params_data["_meta"][RELATED_TASK_METADATA_KEY] = types.RelatedTaskMetadata( - taskId=related_task_id + task_id=related_task_id ).model_dump(by_alias=True) request_id = f"task-{related_task_id}-{id(params)}" if related_task_id else self._request_id @@ -633,15 +635,15 @@ def _build_create_message_request( """ params = types.CreateMessageRequestParams( messages=messages, - systemPrompt=system_prompt, - includeContext=include_context, + system_prompt=system_prompt, + include_context=include_context, temperature=temperature, - maxTokens=max_tokens, - stopSequences=stop_sequences, + max_tokens=max_tokens, + stop_sequences=stop_sequences, metadata=metadata, - modelPreferences=model_preferences, + model_preferences=model_preferences, tools=tools, - toolChoice=tool_choice, + tool_choice=tool_choice, task=task, ) params_data = params.model_dump(by_alias=True, mode="json", exclude_none=True) @@ -649,10 +651,10 @@ def _build_create_message_request( # Add related-task metadata if associated with a parent task if related_task_id is not None: # Defensive: model_dump() never includes _meta, but guard against future changes - if "_meta" not in params_data: # pragma: no cover + if "_meta" not in params_data: # pragma: no branch params_data["_meta"] = {} params_data["_meta"][RELATED_TASK_METADATA_KEY] = types.RelatedTaskMetadata( - taskId=related_task_id + task_id=related_task_id ).model_dump(by_alias=True) request_id = f"task-{related_task_id}-{id(params)}" if related_task_id else self._request_id @@ -685,7 +687,5 @@ async def _handle_incoming(self, req: ServerRequestResponder) -> None: await self._incoming_message_stream_writer.send(req) @property - def incoming_messages( - self, - ) -> MemoryObjectReceiveStream[ServerRequestResponder]: + def incoming_messages(self) -> MemoryObjectReceiveStream[ServerRequestResponder]: return self._incoming_message_stream_reader diff --git a/src/mcp/server/sse.py b/src/mcp/server/sse.py index 19af93fd1..9007230ce 100644 --- a/src/mcp/server/sse.py +++ b/src/mcp/server/sse.py @@ -1,10 +1,9 @@ -""" -SSE Server Transport Module +"""SSE Server Transport Module This module implements a Server-Sent Events (SSE) transport layer for MCP servers. -Example usage: -``` +Example: + ```python # Create an SSE transport at an endpoint sse = SseServerTransport("/messages/") @@ -28,10 +27,10 @@ async def handle_sse(request): # Create and run Starlette app starlette_app = Starlette(routes=routes) uvicorn.run(starlette_app, host="127.0.0.1", port=port) -``` + ``` -Note: The handle_sse function must return a Response to avoid a "TypeError: 'NoneType' -object is not callable" error when client disconnects. The example above returns +Note: The handle_sse function must return a Response to avoid a +"TypeError: 'NoneType' object is not callable" error when client disconnects. The example above returns an empty Response() after the SSE connection ends to fix this. See SseServerTransport class documentation for more details. @@ -51,7 +50,7 @@ async def handle_sse(request): from starlette.responses import Response from starlette.types import Receive, Scope, Send -import mcp.types as types +from mcp import types from mcp.server.transport_security import ( TransportSecurityMiddleware, TransportSecuritySettings, @@ -62,9 +61,8 @@ async def handle_sse(request): class SseServerTransport: - """ - SSE server transport for MCP. This class provides _two_ ASGI applications, - suitable to be used with a framework like Starlette and a server like Hypercorn: + """SSE server transport for MCP. This class provides two ASGI applications, + suitable for use with a framework like Starlette and a server like Hypercorn: 1. connect_sse() is an ASGI application which receives incoming GET requests, and sets up a new SSE stream to send server messages to the client. @@ -78,8 +76,7 @@ class SseServerTransport: _security: TransportSecurityMiddleware def __init__(self, endpoint: str, security_settings: TransportSecuritySettings | None = None) -> None: - """ - Creates a new SSE server transport, which will direct the client to POST + """Creates a new SSE server transport, which will direct the client to POST messages to the relative path given. Args: @@ -173,15 +170,14 @@ async def sse_writer(): await sse_stream_writer.send( { "event": "message", - "data": session_message.message.model_dump_json(by_alias=True, exclude_none=True), + "data": session_message.message.model_dump_json(by_alias=True, exclude_unset=True), } ) async with anyio.create_task_group() as tg: async def response_wrapper(scope: Scope, receive: Receive, send: Send): - """ - The EventSourceResponse returning signals a client close / disconnect. + """The EventSourceResponse returning signals a client close / disconnect. In this case we close our side of the streams to signal the client that the connection has been closed. """ @@ -231,7 +227,7 @@ async def handle_post_message(self, scope: Scope, receive: Receive, send: Send) logger.debug(f"Received JSON: {body}") try: - message = types.JSONRPCMessage.model_validate_json(body) + message = types.jsonrpc_message_adapter.validate_json(body, by_name=False) logger.debug(f"Validated client message: {message}") except ValidationError as err: logger.exception("Failed to parse message") diff --git a/src/mcp/server/stdio.py b/src/mcp/server/stdio.py index bcb9247ab..e526bab56 100644 --- a/src/mcp/server/stdio.py +++ b/src/mcp/server/stdio.py @@ -1,12 +1,11 @@ -""" -Stdio Server Transport Module +"""Stdio Server Transport Module This module provides functionality for creating an stdio-based transport layer that can be used to communicate with an MCP client through standard input/output streams. -Example usage: -``` +Example: + ```python async def run_server(): async with stdio_server() as (read_stream, write_stream): # read_stream contains incoming JSONRPCMessages from stdin @@ -15,7 +14,7 @@ async def run_server(): await server.run(read_stream, write_stream, init_options) anyio.run(run_server) -``` + ``` """ import sys @@ -26,17 +25,13 @@ async def run_server(): import anyio.lowlevel from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -import mcp.types as types +from mcp import types from mcp.shared.message import SessionMessage @asynccontextmanager -async def stdio_server( - stdin: anyio.AsyncFile[str] | None = None, - stdout: anyio.AsyncFile[str] | None = None, -): - """ - Server transport for stdio: this communicates with an MCP client by reading +async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.AsyncFile[str] | None = None): + """Server transport for stdio: this communicates with an MCP client by reading from the current process' stdin and writing to stdout. """ # Purposely not using context managers for these, as we don't want to close @@ -62,7 +57,7 @@ async def stdin_reader(): async with read_stream_writer: async for line in stdin: try: - message = types.JSONRPCMessage.model_validate_json(line) + message = types.jsonrpc_message_adapter.validate_json(line, by_name=False) except Exception as exc: # pragma: no cover await read_stream_writer.send(exc) continue @@ -76,7 +71,7 @@ async def stdout_writer(): try: async with write_stream_reader: async for session_message in write_stream_reader: - json = session_message.message.model_dump_json(by_alias=True, exclude_none=True) + json = session_message.message.model_dump_json(by_alias=True, exclude_unset=True) await stdout.write(json + "\n") await stdout.flush() except anyio.ClosedResourceError: # pragma: no cover diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index 2613b530c..04aed345e 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -1,5 +1,4 @@ -""" -StreamableHTTP Server Transport Module +"""StreamableHTTP Server Transport Module This module implements an HTTP transport layer with Streamable HTTP. @@ -7,7 +6,6 @@ responses, with streaming support for long-running operations. """ -import json import logging import re from abc import ABC, abstractmethod @@ -18,6 +16,7 @@ from typing import Any import anyio +import pydantic_core from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from pydantic import ValidationError from sse_starlette import EventSourceResponse @@ -25,10 +24,7 @@ from starlette.responses import Response from starlette.types import Receive, Scope, Send -from mcp.server.transport_security import ( - TransportSecurityMiddleware, - TransportSecuritySettings, -) +from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings from mcp.shared.message import ServerMessageMetadata, SessionMessage from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS from mcp.types import ( @@ -43,6 +39,7 @@ JSONRPCRequest, JSONRPCResponse, RequestId, + jsonrpc_message_adapter, ) logger = logging.getLogger(__name__) @@ -71,9 +68,7 @@ @dataclass class EventMessage: - """ - A JSONRPCMessage with an optional event ID for stream resumability. - """ + """A JSONRPCMessage with an optional event ID for stream resumability.""" message: JSONRPCMessage event_id: str | None = None @@ -83,21 +78,18 @@ class EventMessage: class EventStore(ABC): - """ - Interface for resumability support via event storage. - """ + """Interface for resumability support via event storage.""" @abstractmethod async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId: - """ - Stores an event for later retrieval. + """Stores an event for later retrieval. Args: stream_id: ID of the stream the event belongs to message: The JSON-RPC message to store, or None for priming events Returns: - The generated event ID for the stored event + The generated event ID for the stored event. """ pass # pragma: no cover @@ -107,22 +99,20 @@ async def replay_events_after( last_event_id: EventId, send_callback: EventCallback, ) -> StreamId | None: - """ - Replays events that occurred after the specified event ID. + """Replays events that occurred after the specified event ID. Args: last_event_id: The ID of the last event the client received send_callback: A callback function to send events to the client Returns: - The stream ID of the replayed events + The stream ID of the replayed events, or None if no events were found. """ pass # pragma: no cover class StreamableHTTPServerTransport: - """ - HTTP server transport with event streaming support for MCP. + """HTTP server transport with event streaming support for MCP. Handles JSON-RPC messages in HTTP POST requests with SSE streaming. Supports optional JSON responses and session management. @@ -143,8 +133,7 @@ def __init__( security_settings: TransportSecuritySettings | None = None, retry_interval: int | None = None, ) -> None: - """ - Initialize a new StreamableHTTP server transport. + """Initialize a new StreamableHTTP server transport. Args: mcp_session_id: Optional session identifier for this connection. @@ -180,6 +169,8 @@ def __init__( ] = {} self._sse_stream_writers: dict[RequestId, MemoryObjectSendStream[dict[str, str]]] = {} self._terminated = False + # Idle timeout cancel scope; managed by the session manager. + self.idle_scope: anyio.CancelScope | None = None @property def is_terminated(self) -> bool: @@ -194,7 +185,7 @@ def close_sse_stream(self, request_id: RequestId) -> None: # pragma: no cover be replayed when the client reconnects with Last-Event-ID. Use this to implement polling behavior during long-running operations - - client will reconnect after the retry interval specified in the priming event. + the client will reconnect after the retry interval specified in the priming event. Args: request_id: The request ID whose SSE stream should be closed. @@ -222,7 +213,7 @@ def close_standalone_sse_stream(self) -> None: # pragma: no cover with Last-Event-ID to resume receiving notifications. Use this to implement polling behavior for the notification stream - - client will reconnect after the retry interval specified in the priming event. + the client will reconnect after the retry interval specified in the priming event. Note: This is a no-op if there is no active standalone SSE stream. @@ -233,7 +224,7 @@ def close_standalone_sse_stream(self) -> None: # pragma: no cover """ self.close_sse_stream(GET_STREAM_KEY) - def _create_session_message( # pragma: no cover + def _create_session_message( self, message: JSONRPCMessage, request: Request, @@ -249,10 +240,10 @@ def _create_session_message( # pragma: no cover # Only provide close callbacks when client supports resumability if self._event_store and protocol_version >= "2025-11-25": - async def close_stream_callback() -> None: + async def close_stream_callback() -> None: # pragma: no cover self.close_sse_stream(request_id) - async def close_standalone_stream_callback() -> None: + async def close_standalone_stream_callback() -> None: # pragma: no cover self.close_standalone_sse_stream() metadata = ServerMessageMetadata( @@ -309,64 +300,61 @@ def _create_error_response( # Return a properly formatted JSON error response error_response = JSONRPCError( jsonrpc="2.0", - id="server-error", # We don't have a request ID for general errors - error=ErrorData( - code=error_code, - message=error_message, - ), + id=None, + error=ErrorData(code=error_code, message=error_message), ) return Response( - error_response.model_dump_json(by_alias=True, exclude_none=True), + error_response.model_dump_json(by_alias=True, exclude_unset=True), status_code=status_code, headers=response_headers, ) - def _create_json_response( # pragma: no cover + def _create_json_response( self, response_message: JSONRPCMessage | None, status_code: HTTPStatus = HTTPStatus.OK, headers: dict[str, str] | None = None, ) -> Response: - """Create a JSON response from a JSONRPCMessage""" + """Create a JSON response from a JSONRPCMessage.""" response_headers = {"Content-Type": CONTENT_TYPE_JSON} - if headers: + if headers: # pragma: lax no cover response_headers.update(headers) - if self.mcp_session_id: + if self.mcp_session_id: # pragma: lax no cover response_headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id return Response( - response_message.model_dump_json(by_alias=True, exclude_none=True) if response_message else None, + response_message.model_dump_json(by_alias=True, exclude_unset=True) if response_message else None, status_code=status_code, headers=response_headers, ) - def _get_session_id(self, request: Request) -> str | None: # pragma: no cover + def _get_session_id(self, request: Request) -> str | None: """Extract the session ID from request headers.""" return request.headers.get(MCP_SESSION_ID_HEADER) - def _create_event_data(self, event_message: EventMessage) -> dict[str, str]: # pragma: no cover + def _create_event_data(self, event_message: EventMessage) -> dict[str, str]: """Create event data dictionary from an EventMessage.""" event_data = { "event": "message", - "data": event_message.message.model_dump_json(by_alias=True, exclude_none=True), + "data": event_message.message.model_dump_json(by_alias=True, exclude_unset=True), } # If an event ID was provided, include it - if event_message.event_id: + if event_message.event_id: # pragma: no cover event_data["id"] = event_message.event_id return event_data - async def _clean_up_memory_streams(self, request_id: RequestId) -> None: # pragma: no cover + async def _clean_up_memory_streams(self, request_id: RequestId) -> None: """Clean up memory streams for a given request ID.""" - if request_id in self._request_streams: + if request_id in self._request_streams: # pragma: no branch try: # Close the request stream await self._request_streams[request_id][0].aclose() await self._request_streams[request_id][1].aclose() - except Exception: + except Exception: # pragma: no cover # During cleanup, we catch all exceptions since streams might be in various states logger.debug("Error closing memory streams - may already be closed") finally: @@ -374,13 +362,13 @@ async def _clean_up_memory_streams(self, request_id: RequestId) -> None: # prag self._request_streams.pop(request_id, None) async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> None: - """Application entry point that handles all HTTP requests""" + """Application entry point that handles all HTTP requests.""" request = Request(scope, receive) # Validate request headers for DNS rebinding protection is_post = request.method == "POST" error_response = await self._security.validate_request(request, is_post=is_post) - if error_response: # pragma: no cover + if error_response: await error_response(scope, receive, send) return @@ -395,9 +383,9 @@ async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> No if request.method == "POST": await self._handle_post_request(scope, request, receive, send) - elif request.method == "GET": # pragma: no cover + elif request.method == "GET": await self._handle_get_request(request, send) - elif request.method == "DELETE": # pragma: no cover + elif request.method == "DELETE": await self._handle_delete_request(request, send) else: # pragma: no cover await self._handle_unsupported_request(request, send) @@ -419,12 +407,12 @@ def _check_content_type(self, request: Request) -> bool: return any(part == CONTENT_TYPE_JSON for part in content_type_parts) - async def _validate_accept_header(self, request: Request, scope: Scope, send: Send) -> bool: # pragma: no cover + async def _validate_accept_header(self, request: Request, scope: Scope, send: Send) -> bool: """Validate Accept header based on response mode. Returns True if valid.""" has_json, has_sse = self._check_accept_headers(request) if self.is_json_response_enabled: # For JSON-only responses, only require application/json - if not has_json: + if not has_json: # pragma: lax no cover response = self._create_error_response( "Not Acceptable: Client must accept application/json", HTTPStatus.NOT_ACCEPTABLE, @@ -464,14 +452,14 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re body = await request.body() try: - raw_message = json.loads(body) - except json.JSONDecodeError as e: + raw_message = pydantic_core.from_json(body) + except ValueError as e: response = self._create_error_response(f"Parse error: {str(e)}", HTTPStatus.BAD_REQUEST, PARSE_ERROR) await response(scope, receive, send) return - try: # pragma: no cover - message = JSONRPCMessage.model_validate(raw_message) + try: + message = jsonrpc_message_adapter.validate_python(raw_message, by_name=False) except ValidationError as e: # pragma: no cover response = self._create_error_response( f"Validation error: {str(e)}", @@ -482,18 +470,16 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re return # Check if this is an initialization request - is_initialization_request = ( - isinstance(message.root, JSONRPCRequest) and message.root.method == "initialize" - ) # pragma: no cover + is_initialization_request = isinstance(message, JSONRPCRequest) and message.method == "initialize" - if is_initialization_request: # pragma: no cover + if is_initialization_request: # Check if the server already has an established session if self.mcp_session_id: # Check if request has a session ID request_session_id = self._get_session_id(request) # If request has a session ID but doesn't match, return 404 - if request_session_id and request_session_id != self.mcp_session_id: + if request_session_id and request_session_id != self.mcp_session_id: # pragma: no cover response = self._create_error_response( "Not Found: Invalid or expired session ID", HTTPStatus.NOT_FOUND, @@ -504,7 +490,7 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re return # For notifications and responses only, return 202 Accepted - if not isinstance(message.root, JSONRPCRequest): # pragma: no cover + if not isinstance(message, JSONRPCRequest): # Create response object and send it response = self._create_json_response( None, @@ -523,18 +509,18 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re # For initialize requests, get from request params. # For other requests, get from header (already validated). protocol_version = ( - str(message.root.params.get("protocolVersion", DEFAULT_NEGOTIATED_VERSION)) - if is_initialization_request and message.root.params + str(message.params.get("protocolVersion", DEFAULT_NEGOTIATED_VERSION)) + if is_initialization_request and message.params else request.headers.get(MCP_PROTOCOL_VERSION_HEADER, DEFAULT_NEGOTIATED_VERSION) ) # Extract the request ID outside the try block for proper scope - request_id = str(message.root.id) # pragma: no cover + request_id = str(message.id) # Register this stream for the request ID - self._request_streams[request_id] = anyio.create_memory_object_stream[EventMessage](0) # pragma: no cover - request_stream_reader = self._request_streams[request_id][1] # pragma: no cover + self._request_streams[request_id] = anyio.create_memory_object_stream[EventMessage](0) + request_stream_reader = self._request_streams[request_id][1] - if self.is_json_response_enabled: # pragma: no cover + if self.is_json_response_enabled: # Process the message metadata = ServerMessageMetadata(request_context=request) session_message = SessionMessage(message, metadata=metadata) @@ -545,21 +531,21 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re response_message = None # Use similar approach to SSE writer for consistency - async for event_message in request_stream_reader: + async for event_message in request_stream_reader: # pragma: no branch # If it's a response, this is what we're waiting for - if isinstance(event_message.message.root, JSONRPCResponse | JSONRPCError): + if isinstance(event_message.message, JSONRPCResponse | JSONRPCError): response_message = event_message.message break - # For notifications and request, keep waiting - else: - logger.debug(f"received: {event_message.message.root.method}") + # For notifications and requests, keep waiting + else: # pragma: no cover + logger.debug(f"received: {event_message.message.method}") # At this point we should have a response if response_message: # Create JSON response response = self._create_json_response(response_message) await response(scope, receive, send) - else: + else: # pragma: no cover # This shouldn't happen in normal operation logger.error("No response message received before stream closed") response = self._create_error_response( @@ -567,7 +553,7 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re HTTPStatus.INTERNAL_SERVER_ERROR, ) await response(scope, receive, send) - except Exception: + except Exception: # pragma: no cover logger.exception("Error processing JSON response") response = self._create_error_response( "Error processing request", @@ -577,14 +563,14 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re await response(scope, receive, send) finally: await self._clean_up_memory_streams(request_id) - else: # pragma: no cover + else: # Create SSE stream sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[dict[str, str]](0) # Store writer reference so close_sse_stream() can close it self._sse_stream_writers[request_id] = sse_stream_writer - async def sse_writer(): + async def sse_writer(): # pragma: lax no cover # Get the request ID from the incoming request message try: async with sse_stream_writer, request_stream_reader: @@ -598,10 +584,7 @@ async def sse_writer(): await sse_stream_writer.send(event_data) # If response, remove from pending streams and close - if isinstance( - event_message.message.root, - JSONRPCResponse | JSONRPCError, - ): + if isinstance(event_message.message, JSONRPCResponse | JSONRPCError): break except anyio.ClosedResourceError: # Expected when close_sse_stream() is called @@ -636,11 +619,12 @@ async def sse_writer(): # Then send the message to be processed by the server session_message = self._create_session_message(message, request, request_id, protocol_version) await writer.send(session_message) - except Exception: + except Exception: # pragma: no cover logger.exception("SSE response error") await sse_stream_writer.aclose() - await sse_stream_reader.aclose() await self._clean_up_memory_streams(request_id) + finally: + await sse_stream_reader.aclose() except Exception as err: # pragma: no cover logger.exception("Error handling POST request") @@ -654,22 +638,21 @@ async def sse_writer(): await writer.send(Exception(err)) return - async def _handle_get_request(self, request: Request, send: Send) -> None: # pragma: no cover - """ - Handle GET request to establish SSE. + async def _handle_get_request(self, request: Request, send: Send) -> None: + """Handle GET request to establish SSE. This allows the server to communicate to the client without the client first sending data via HTTP POST. The server can send JSON-RPC requests and notifications on this stream. """ writer = self._read_stream_writer - if writer is None: + if writer is None: # pragma: no cover raise ValueError("No read stream writer available. Ensure connect() is called first.") # Validate Accept header - must include text/event-stream _, has_sse = self._check_accept_headers(request) - if not has_sse: + if not has_sse: # pragma: no cover response = self._create_error_response( "Not Acceptable: Client must accept text/event-stream", HTTPStatus.NOT_ACCEPTABLE, @@ -677,11 +660,11 @@ async def _handle_get_request(self, request: Request, send: Send) -> None: # pr await response(request.scope, request.receive, send) return - if not await self._validate_request_headers(request, send): + if not await self._validate_request_headers(request, send): # pragma: no cover return # Handle resumability: check for Last-Event-ID header - if last_event_id := request.headers.get(LAST_EVENT_ID_HEADER): + if last_event_id := request.headers.get(LAST_EVENT_ID_HEADER): # pragma: no cover await self._replay_events(last_event_id, request, send) return @@ -695,7 +678,7 @@ async def _handle_get_request(self, request: Request, send: Send) -> None: # pr headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id # Check if we already have an active GET stream - if GET_STREAM_KEY in self._request_streams: + if GET_STREAM_KEY in self._request_streams: # pragma: no cover response = self._create_error_response( "Conflict: Only one SSE stream is allowed per session", HTTPStatus.CONFLICT, @@ -715,7 +698,7 @@ async def standalone_sse_writer(): async with sse_stream_writer, standalone_stream_reader: # Process messages from the standalone stream - async for event_message in standalone_stream_reader: + async for event_message in standalone_stream_reader: # pragma: lax no cover # For the standalone stream, we handle: # - JSONRPCNotification (server sends notifications to client) # - JSONRPCRequest (server sends requests to client) @@ -724,7 +707,7 @@ async def standalone_sse_writer(): # Send the message via SSE event_data = self._create_event_data(event_message) await sse_stream_writer.send(event_data) - except Exception: + except Exception: # pragma: no cover logger.exception("Error in standalone SSE writer") finally: logger.debug("Closing standalone SSE writer") @@ -740,16 +723,17 @@ async def standalone_sse_writer(): try: # This will send headers immediately and establish the SSE connection await response(request.scope, request.receive, send) - except Exception: + except Exception: # pragma: lax no cover logger.exception("Error in standalone SSE response") + await self._clean_up_memory_streams(GET_STREAM_KEY) + finally: await sse_stream_writer.aclose() await sse_stream_reader.aclose() - await self._clean_up_memory_streams(GET_STREAM_KEY) - async def _handle_delete_request(self, request: Request, send: Send) -> None: # pragma: no cover + async def _handle_delete_request(self, request: Request, send: Send) -> None: """Handle DELETE requests for explicit session termination.""" # Validate session ID - if not self.mcp_session_id: + if not self.mcp_session_id: # pragma: no cover # If no session ID set, return Method Not Allowed response = self._create_error_response( "Method Not Allowed: Session termination not supported", @@ -758,7 +742,7 @@ async def _handle_delete_request(self, request: Request, send: Send) -> None: # await response(request.scope, request.receive, send) return - if not await self._validate_request_headers(request, send): + if not await self._validate_request_headers(request, send): # pragma: no cover return await self.terminate() @@ -782,7 +766,7 @@ async def terminate(self) -> None: request_stream_keys = list(self._request_streams.keys()) # Close all request streams asynchronously - for key in request_stream_keys: # pragma: no cover + for key in request_stream_keys: # pragma: lax no cover await self._clean_up_memory_streams(key) # Clear the request streams dictionary immediately @@ -816,16 +800,16 @@ async def _handle_unsupported_request(self, request: Request, send: Send) -> Non ) await response(request.scope, request.receive, send) - async def _validate_request_headers(self, request: Request, send: Send) -> bool: # pragma: no cover + async def _validate_request_headers(self, request: Request, send: Send) -> bool: # pragma: lax no cover if not await self._validate_session(request, send): return False if not await self._validate_protocol_version(request, send): return False return True - async def _validate_session(self, request: Request, send: Send) -> bool: # pragma: no cover + async def _validate_session(self, request: Request, send: Send) -> bool: """Validate the session ID in the request.""" - if not self.mcp_session_id: + if not self.mcp_session_id: # pragma: no cover # If we're not using session IDs, return True return True @@ -833,7 +817,7 @@ async def _validate_session(self, request: Request, send: Send) -> bool: # prag request_session_id = self._get_session_id(request) # If no session ID provided but required, return error - if not request_session_id: + if not request_session_id: # pragma: no cover response = self._create_error_response( "Bad Request: Missing session ID", HTTPStatus.BAD_REQUEST, @@ -842,7 +826,7 @@ async def _validate_session(self, request: Request, send: Send) -> bool: # prag return False # If session ID doesn't match, return error - if request_session_id != self.mcp_session_id: + if request_session_id != self.mcp_session_id: # pragma: no cover response = self._create_error_response( "Not Found: Invalid or expired session ID", HTTPStatus.NOT_FOUND, @@ -852,17 +836,17 @@ async def _validate_session(self, request: Request, send: Send) -> bool: # prag return True - async def _validate_protocol_version(self, request: Request, send: Send) -> bool: # pragma: no cover + async def _validate_protocol_version(self, request: Request, send: Send) -> bool: """Validate the protocol version header in the request.""" # Get the protocol version from the request headers protocol_version = request.headers.get(MCP_PROTOCOL_VERSION_HEADER) # If no protocol version provided, assume default version - if protocol_version is None: + if protocol_version is None: # pragma: no cover protocol_version = DEFAULT_NEGOTIATED_VERSION # Check if the protocol version is supported - if protocol_version not in SUPPORTED_PROTOCOL_VERSIONS: + if protocol_version not in SUPPORTED_PROTOCOL_VERSIONS: # pragma: no cover supported_versions = ", ".join(SUPPORTED_PROTOCOL_VERSIONS) response = self._create_error_response( f"Bad Request: Unsupported protocol version: {protocol_version}. " @@ -875,8 +859,8 @@ async def _validate_protocol_version(self, request: Request, send: Send) -> bool return True async def _replay_events(self, last_event_id: str, request: Request, send: Send) -> None: # pragma: no cover - """ - Replays events that would have been sent after the specified event ID. + """Replays events that would have been sent after the specified event ID. + Only used when resumability is enabled. """ event_store = self._event_store @@ -988,20 +972,19 @@ async def connect( # Start a task group for message routing async with anyio.create_task_group() as tg: # Create a message router that distributes messages to request streams - async def message_router(): # pragma: no cover + async def message_router(): try: - async for session_message in write_stream_reader: + async for session_message in write_stream_reader: # pragma: no branch # Determine which request stream(s) should receive this message message = session_message.message target_request_id = None - # Check if this is a response - if isinstance(message.root, JSONRPCResponse | JSONRPCError): - response_id = str(message.root.id) - # If this response is for an existing request stream, - # send it there - target_request_id = response_id + # Check if this is a response with a known request id. + # Null-id errors (e.g., parse errors) fall through to + # the GET stream since they can't be correlated. + if isinstance(message, JSONRPCResponse | JSONRPCError) and message.id is not None: + target_request_id = str(message.id) # Extract related_request_id from meta if it exists - elif ( + elif ( # pragma: no cover session_message.metadata is not None and isinstance( session_message.metadata, @@ -1017,7 +1000,7 @@ async def message_router(): # pragma: no cover # regardless of whether a client is connected # messages will be replayed on the re-connect event_id = None - if self._event_store: + if self._event_store: # pragma: lax no cover event_id = await self._event_store.store_event(request_stream_id, message) logger.debug(f"Stored {event_id} from {request_stream_id}") @@ -1025,15 +1008,12 @@ async def message_router(): # pragma: no cover try: # Send both the message and the event ID await self._request_streams[request_stream_id][0].send(EventMessage(message, event_id)) - except ( - anyio.BrokenResourceError, - anyio.ClosedResourceError, - ): + except (anyio.BrokenResourceError, anyio.ClosedResourceError): # pragma: no cover # Stream might be closed, remove from registry self._request_streams.pop(request_stream_id, None) - else: + else: # pragma: no cover logger.debug( - f"""Request stream {request_stream_id} not found + f"""Request stream {request_stream_id} not found for message. Still processing message as the client might reconnect and replay.""" ) @@ -1042,7 +1022,7 @@ async def message_router(): # pragma: no cover logger.debug("Read stream closed by client") else: logger.exception("Unexpected closure of read stream in message router") - except Exception: + except Exception: # pragma: lax no cover logger.exception("Error in message router") # Start the message router @@ -1052,7 +1032,7 @@ async def message_router(): # pragma: no cover # Yield the streams for the caller to use yield read_stream, write_stream finally: - for stream_id in list(self._request_streams.keys()): # pragma: no cover + for stream_id in list(self._request_streams.keys()): # pragma: lax no cover await self._clean_up_memory_streams(stream_id) self._request_streams.clear() diff --git a/src/mcp/server/streamable_http_manager.py b/src/mcp/server/streamable_http_manager.py index 50d2aefa2..50bcd5e79 100644 --- a/src/mcp/server/streamable_http_manager.py +++ b/src/mcp/server/streamable_http_manager.py @@ -6,7 +6,7 @@ import logging from collections.abc import AsyncIterator from http import HTTPStatus -from typing import Any +from typing import TYPE_CHECKING, Any from uuid import uuid4 import anyio @@ -15,20 +15,22 @@ from starlette.responses import Response from starlette.types import Receive, Scope, Send -from mcp.server.lowlevel.server import Server as MCPServer from mcp.server.streamable_http import ( MCP_SESSION_ID_HEADER, EventStore, StreamableHTTPServerTransport, ) from mcp.server.transport_security import TransportSecuritySettings +from mcp.types import INVALID_REQUEST, ErrorData, JSONRPCError + +if TYPE_CHECKING: + from mcp.server.lowlevel.server import Server logger = logging.getLogger(__name__) class StreamableHTTPSessionManager: - """ - Manages StreamableHTTP sessions with optional resumability via event store. + """Manages StreamableHTTP sessions with optional resumability via event store. This class abstracts away the complexity of session management, event storage, and request handling for StreamableHTTP transports. It handles: @@ -37,6 +39,7 @@ class StreamableHTTPSessionManager: 2. Resumability via an optional event store 3. Connection management and lifecycle 4. Request handling and transport setup + 5. Idle session cleanup via optional timeout Important: Only one StreamableHTTPSessionManager instance should be created per application. The instance cannot be reused after its run() context has @@ -44,33 +47,44 @@ class StreamableHTTPSessionManager: Args: app: The MCP server instance - event_store: Optional event store for resumability support. - If provided, enables resumable connections where clients - can reconnect and receive missed events. - If None, sessions are still tracked but not resumable. + event_store: Optional event store for resumability support. If provided, enables resumable connections + where clients can reconnect and receive missed events. If None, sessions are still tracked but not + resumable. json_response: Whether to use JSON responses instead of SSE streams - stateless: If True, creates a completely fresh transport for each request - with no session tracking or state persistence between requests. + stateless: If True, creates a completely fresh transport for each request with no session tracking or + state persistence between requests. security_settings: Optional transport security settings. - retry_interval: Retry interval in milliseconds to suggest to clients in SSE - retry field. Used for SSE polling behavior. + retry_interval: Retry interval in milliseconds to suggest to clients in SSE retry field. Used for SSE + polling behavior. + session_idle_timeout: Optional idle timeout in seconds for stateful sessions. If set, sessions that + receive no HTTP requests for this duration will be automatically terminated and removed. When + retry_interval is also configured, ensure the idle timeout comfortably exceeds the retry interval to + avoid reaping sessions during normal SSE polling gaps. Default is None (no timeout). A value of 1800 + (30 minutes) is recommended for most deployments. """ def __init__( self, - app: MCPServer[Any, Any], + app: Server[Any], event_store: EventStore | None = None, json_response: bool = False, stateless: bool = False, security_settings: TransportSecuritySettings | None = None, retry_interval: int | None = None, + session_idle_timeout: float | None = None, ): + if session_idle_timeout is not None and session_idle_timeout <= 0: + raise ValueError("session_idle_timeout must be a positive number of seconds") + if stateless and session_idle_timeout is not None: + raise RuntimeError("session_idle_timeout is not supported in stateless mode") + self.app = app self.event_store = event_store self.json_response = json_response self.stateless = stateless self.security_settings = security_settings self.retry_interval = retry_interval + self.session_idle_timeout = session_idle_timeout # Session tracking (only used if not stateless) self._session_creation_lock = anyio.Lock() @@ -84,8 +98,7 @@ def __init__( @contextlib.asynccontextmanager async def run(self) -> AsyncIterator[None]: - """ - Run the session manager with proper lifecycle management. + """Run the session manager with proper lifecycle management. This creates and manages the task group for all session operations. @@ -123,21 +136,10 @@ async def lifespan(app: Starlette) -> AsyncIterator[None]: # Clear any remaining server instances self._server_instances.clear() - async def handle_request( - self, - scope: Scope, - receive: Receive, - send: Send, - ) -> None: - """ - Process ASGI request with proper session handling and transport setup. + async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> None: + """Process ASGI request with proper session handling and transport setup. Dispatches to the appropriate handler based on stateless mode. - - Args: - scope: ASGI scope - receive: ASGI receive function - send: ASGI send function """ if self._task_group is None: raise RuntimeError("Task group is not initialized. Make sure to use run().") @@ -148,20 +150,8 @@ async def handle_request( else: await self._handle_stateful_request(scope, receive, send) - async def _handle_stateless_request( - self, - scope: Scope, - receive: Receive, - send: Send, - ) -> None: - """ - Process request in stateless mode - creating a new transport for each request. - - Args: - scope: ASGI scope - receive: ASGI receive function - send: ASGI send function - """ + async def _handle_stateless_request(self, scope: Scope, receive: Receive, send: Send) -> None: + """Process request in stateless mode - creating a new transport for each request.""" logger.debug("Stateless mode: Creating new transport for this request") # No session ID needed in stateless mode http_transport = StreamableHTTPServerTransport( @@ -197,27 +187,18 @@ async def run_stateless_server(*, task_status: TaskStatus[None] = anyio.TASK_STA # Terminate the transport after the request is handled await http_transport.terminate() - async def _handle_stateful_request( - self, - scope: Scope, - receive: Receive, - send: Send, - ) -> None: - """ - Process request in stateful mode - maintaining session state between requests. - - Args: - scope: ASGI scope - receive: ASGI receive function - send: ASGI send function - """ + async def _handle_stateful_request(self, scope: Scope, receive: Receive, send: Send) -> None: + """Process request in stateful mode - maintaining session state between requests.""" request = Request(scope, receive) request_mcp_session_id = request.headers.get(MCP_SESSION_ID_HEADER) # Existing session case - if request_mcp_session_id is not None and request_mcp_session_id in self._server_instances: # pragma: no cover + if request_mcp_session_id is not None and request_mcp_session_id in self._server_instances: transport = self._server_instances[request_mcp_session_id] logger.debug("Session already exists, handling request directly") + # Push back idle deadline on activity + if transport.idle_scope is not None and self.session_idle_timeout is not None: + transport.idle_scope.deadline = anyio.current_time() + self.session_idle_timeout # pragma: no cover await transport.handle_request(scope, receive, send) return @@ -244,19 +225,31 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE read_stream, write_stream = streams task_status.started() try: - await self.app.run( - read_stream, - write_stream, - self.app.create_initialization_options(), - stateless=False, # Stateful mode - ) - except Exception as e: - logger.error( - f"Session {http_transport.mcp_session_id} crashed: {e}", - exc_info=True, - ) + # Use a cancel scope for idle timeout — when the + # deadline passes the scope cancels app.run() and + # execution continues after the ``with`` block. + # Incoming requests push the deadline forward. + idle_scope = anyio.CancelScope() + if self.session_idle_timeout is not None: + idle_scope.deadline = anyio.current_time() + self.session_idle_timeout + http_transport.idle_scope = idle_scope + + with idle_scope: + await self.app.run( + read_stream, + write_stream, + self.app.create_initialization_options(), + stateless=False, + ) + + if idle_scope.cancelled_caught: + assert http_transport.mcp_session_id is not None + logger.info(f"Session {http_transport.mcp_session_id} idle timeout") + self._server_instances.pop(http_transport.mcp_session_id, None) + await http_transport.terminate() + except Exception: + logger.exception(f"Session {http_transport.mcp_session_id} crashed") finally: - # Only remove from instances if not terminated if ( # pragma: no branch http_transport.mcp_session_id and http_transport.mcp_session_id in self._server_instances @@ -264,8 +257,7 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE ): logger.info( "Cleaning up crashed session " - f"{http_transport.mcp_session_id} from " - "active instances." + f"{http_transport.mcp_session_id} from active instances." ) del self._server_instances[http_transport.mcp_session_id] @@ -276,10 +268,28 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE # Handle the HTTP request and return the response await http_transport.handle_request(scope, receive, send) - else: # pragma: no cover - # Invalid session ID + else: + # Unknown or expired session ID - return 404 per MCP spec + # TODO: Align error code once spec clarifies + # See: https://github.com/modelcontextprotocol/python-sdk/issues/1821 + error_response = JSONRPCError( + jsonrpc="2.0", + id=None, + error=ErrorData(code=INVALID_REQUEST, message="Session not found"), + ) response = Response( - "Bad Request: No valid session ID provided", - status_code=HTTPStatus.BAD_REQUEST, + content=error_response.model_dump_json(by_alias=True, exclude_unset=True), + status_code=HTTPStatus.NOT_FOUND, + media_type="application/json", ) await response(scope, receive, send) + + +class StreamableHTTPASGIApp: + """ASGI application for Streamable HTTP server transport.""" + + def __init__(self, session_manager: StreamableHTTPSessionManager): + self.session_manager = session_manager + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + await self.session_manager.handle_request(scope, receive, send) diff --git a/src/mcp/server/transport_security.py b/src/mcp/server/transport_security.py index ee1e4505a..1ed9842c0 100644 --- a/src/mcp/server/transport_security.py +++ b/src/mcp/server/transport_security.py @@ -9,37 +9,35 @@ logger = logging.getLogger(__name__) +# TODO(Marcelo): We should flatten these settings. To be fair, I don't think we should even have this middleware. class TransportSecuritySettings(BaseModel): """Settings for MCP transport security features. - These settings help protect against DNS rebinding attacks by validating - incoming request headers. + These settings help protect against DNS rebinding attacks by validating incoming request headers. """ - enable_dns_rebinding_protection: bool = Field( - default=True, - description="Enable DNS rebinding protection (recommended for production)", - ) + enable_dns_rebinding_protection: bool = True + """Enable DNS rebinding protection (recommended for production).""" - allowed_hosts: list[str] = Field( - default=[], - description="List of allowed Host header values. Only applies when " - + "enable_dns_rebinding_protection is True.", - ) + allowed_hosts: list[str] = Field(default_factory=list) + """List of allowed Host header values. - allowed_origins: list[str] = Field( - default=[], - description="List of allowed Origin header values. Only applies when " - + "enable_dns_rebinding_protection is True.", - ) + Only applies when `enable_dns_rebinding_protection` is `True`. + """ + + allowed_origins: list[str] = Field(default_factory=list) + """List of allowed Origin header values. + + Only applies when `enable_dns_rebinding_protection` is `True`. + """ +# TODO(Marcelo): This should be a proper ASGI middleware. I'm sad to see this. class TransportSecurityMiddleware: """Middleware to enforce DNS rebinding protection for MCP transport endpoints.""" def __init__(self, settings: TransportSecuritySettings | None = None): - # If not specified, disable DNS rebinding protection by default - # for backwards compatibility + # If not specified, disable DNS rebinding protection by default for backwards compatibility self.settings = settings or TransportSecuritySettings(enable_dns_rebinding_protection=False) def _validate_host(self, host: str | None) -> bool: # pragma: no cover @@ -86,18 +84,9 @@ def _validate_origin(self, origin: str | None) -> bool: # pragma: no cover logger.warning(f"Invalid Origin header: {origin}") return False - def _validate_content_type(self, content_type: str | None) -> bool: # pragma: no cover + def _validate_content_type(self, content_type: str | None) -> bool: """Validate the Content-Type header for POST requests.""" - if not content_type: - logger.warning("Missing Content-Type header in POST request") - return False - - # Content-Type must start with application/json - if not content_type.lower().startswith("application/json"): - logger.warning(f"Invalid Content-Type header: {content_type}") - return False - - return True + return content_type is not None and content_type.lower().startswith("application/json") async def validate_request(self, request: Request, is_post: bool = False) -> Response | None: """Validate request headers for DNS rebinding protection. @@ -107,7 +96,7 @@ async def validate_request(self, request: Request, is_post: bool = False) -> Res # Always validate Content-Type for POST requests if is_post: # pragma: no branch content_type = request.headers.get("content-type") - if not self._validate_content_type(content_type): # pragma: no cover + if not self._validate_content_type(content_type): return Response("Invalid Content-Type header", status_code=400) # Skip remaining validation if DNS rebinding protection is disabled diff --git a/src/mcp/server/validation.py b/src/mcp/server/validation.py index 2ccd7056b..570862807 100644 --- a/src/mcp/server/validation.py +++ b/src/mcp/server/validation.py @@ -1,24 +1,15 @@ -""" -Shared validation functions for server requests. +"""Shared validation functions for server requests. This module provides validation logic for sampling and elicitation requests that is shared across normal and task-augmented code paths. """ -from mcp.shared.exceptions import McpError -from mcp.types import ( - INVALID_PARAMS, - ClientCapabilities, - ErrorData, - SamplingMessage, - Tool, - ToolChoice, -) +from mcp.shared.exceptions import MCPError +from mcp.types import INVALID_PARAMS, ClientCapabilities, SamplingMessage, Tool, ToolChoice def check_sampling_tools_capability(client_caps: ClientCapabilities | None) -> bool: - """ - Check if the client supports sampling tools capability. + """Check if the client supports sampling tools capability. Args: client_caps: The client's declared capabilities @@ -40,8 +31,7 @@ def validate_sampling_tools( tools: list[Tool] | None, tool_choice: ToolChoice | None, ) -> None: - """ - Validate that the client supports sampling tools if tools are being used. + """Validate that the client supports sampling tools if tools are being used. Args: client_caps: The client's declared capabilities @@ -49,21 +39,15 @@ def validate_sampling_tools( tool_choice: The tool choice setting, if provided Raises: - McpError: If tools/tool_choice are provided but client doesn't support them + MCPError: If tools/tool_choice are provided but client doesn't support them """ if tools is not None or tool_choice is not None: if not check_sampling_tools_capability(client_caps): - raise McpError( - ErrorData( - code=INVALID_PARAMS, - message="Client does not support sampling tools capability", - ) - ) + raise MCPError(code=INVALID_PARAMS, message="Client does not support sampling tools capability") def validate_tool_use_result_messages(messages: list[SamplingMessage]) -> None: - """ - Validate tool_use/tool_result message structure per SEP-1577. + """Validate tool_use/tool_result message structure per SEP-1577. This validation ensures: 1. Messages with tool_result content contain ONLY tool_result content @@ -99,6 +83,6 @@ def validate_tool_use_result_messages(messages: list[SamplingMessage]) -> None: if has_previous_tool_use and previous_content: tool_use_ids = {c.id for c in previous_content if c.type == "tool_use"} - tool_result_ids = {c.toolUseId for c in last_content if c.type == "tool_result"} + tool_result_ids = {c.tool_use_id for c in last_content if c.type == "tool_result"} if tool_use_ids != tool_result_ids: raise ValueError("ids of tool_result blocks and tool_use blocks from previous message do not match") diff --git a/src/mcp/server/websocket.py b/src/mcp/server/websocket.py index 5d5efd16e..3e675da5f 100644 --- a/src/mcp/server/websocket.py +++ b/src/mcp/server/websocket.py @@ -1,4 +1,3 @@ -import logging from contextlib import asynccontextmanager import anyio @@ -7,17 +6,14 @@ from starlette.types import Receive, Scope, Send from starlette.websockets import WebSocket -import mcp.types as types +from mcp import types from mcp.shared.message import SessionMessage -logger = logging.getLogger(__name__) - @asynccontextmanager # pragma: no cover async def websocket_server(scope: Scope, receive: Receive, send: Send): - """ - WebSocket server transport for MCP. This is an ASGI application, suitable to be - used with a framework like Starlette and a server like Hypercorn. + """WebSocket server transport for MCP. This is an ASGI application, suitable for use + with a framework like Starlette and a server like Hypercorn. """ websocket = WebSocket(scope, receive, send) @@ -37,7 +33,7 @@ async def ws_reader(): async with read_stream_writer: async for msg in websocket.iter_text(): try: - client_message = types.JSONRPCMessage.model_validate_json(msg) + client_message = types.jsonrpc_message_adapter.validate_json(msg, by_name=False) except ValidationError as exc: await read_stream_writer.send(exc) continue @@ -51,7 +47,7 @@ async def ws_writer(): try: async with write_stream_reader: async for session_message in write_stream_reader: - obj = session_message.message.model_dump_json(by_alias=True, exclude_none=True) + obj = session_message.message.model_dump_json(by_alias=True, exclude_unset=True) await websocket.send_text(obj) except anyio.ClosedResourceError: await websocket.close() diff --git a/src/mcp/shared/_context.py b/src/mcp/shared/_context.py new file mode 100644 index 000000000..bbcee2d02 --- /dev/null +++ b/src/mcp/shared/_context.py @@ -0,0 +1,24 @@ +"""Request context for MCP handlers.""" + +from dataclasses import dataclass +from typing import Any, Generic + +from typing_extensions import TypeVar + +from mcp.shared.session import BaseSession +from mcp.types import RequestId, RequestParamsMeta + +SessionT = TypeVar("SessionT", bound=BaseSession[Any, Any, Any, Any, Any]) + + +@dataclass(kw_only=True) +class RequestContext(Generic[SessionT]): + """Common context for handling incoming requests. + + For request handlers, request_id is always populated. + For notification handlers, request_id is None. + """ + + session: SessionT + request_id: RequestId | None = None + meta: RequestParamsMeta | None = None diff --git a/src/mcp/shared/_httpx_utils.py b/src/mcp/shared/_httpx_utils.py index 3af64ad1e..251469eaa 100644 --- a/src/mcp/shared/_httpx_utils.py +++ b/src/mcp/shared/_httpx_utils.py @@ -4,7 +4,11 @@ import httpx -__all__ = ["create_mcp_http_client"] +__all__ = ["create_mcp_http_client", "MCP_DEFAULT_TIMEOUT", "MCP_DEFAULT_SSE_READ_TIMEOUT"] + +# Default MCP timeout configuration +MCP_DEFAULT_TIMEOUT = 30.0 # General operations (seconds) +MCP_DEFAULT_SSE_READ_TIMEOUT = 300.0 # SSE streams - 5 minutes (seconds) class McpHttpClientFactory(Protocol): # pragma: no branch @@ -40,35 +44,45 @@ def create_mcp_http_client( The returned AsyncClient must be used as a context manager to ensure proper cleanup of connections. - Examples: - # Basic usage with MCP defaults + Example: + Basic usage with MCP defaults: + + ```python async with create_mcp_http_client() as client: response = await client.get("https://api.example.com") + ``` + + With custom headers: - # With custom headers + ```python headers = {"Authorization": "Bearer token"} async with create_mcp_http_client(headers) as client: response = await client.get("/endpoint") + ``` - # With both custom headers and timeout + With both custom headers and timeout: + + ```python timeout = httpx.Timeout(60.0, read=300.0) async with create_mcp_http_client(headers, timeout) as client: response = await client.get("/long-request") + ``` + + With authentication: - # With authentication + ```python from httpx import BasicAuth auth = BasicAuth(username="user", password="pass") async with create_mcp_http_client(headers, timeout, auth) as client: response = await client.get("/protected-endpoint") + ``` """ # Set MCP defaults - kwargs: dict[str, Any] = { - "follow_redirects": True, - } + kwargs: dict[str, Any] = {"follow_redirects": True} # Handle timeout if timeout is None: - kwargs["timeout"] = httpx.Timeout(30.0) + kwargs["timeout"] = httpx.Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT) else: kwargs["timeout"] = timeout diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index d3290997e..ca5b7b45a 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -4,9 +4,7 @@ class OAuthToken(BaseModel): - """ - See https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 - """ + """See https://datatracker.ietf.org/doc/html/rfc6749#section-5.1""" access_token: str token_type: Literal["Bearer"] = "Bearer" @@ -35,10 +33,8 @@ def __init__(self, message: str): class OAuthClientMetadata(BaseModel): - """ - RFC 7591 OAuth 2.0 Dynamic Client Registration metadata. + """RFC 7591 OAuth 2.0 Dynamic Client Registration Metadata. See https://datatracker.ietf.org/doc/html/rfc7591#section-2 - for the full specification. """ redirect_uris: list[AnyUrl] | None = Field(..., min_length=1) @@ -94,8 +90,7 @@ def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl: class OAuthClientInformationFull(OAuthClientMetadata): - """ - RFC 7591 OAuth 2.0 Dynamic Client Registration full response + """RFC 7591 OAuth 2.0 Dynamic Client Registration full response (client information plus metadata). """ @@ -106,8 +101,7 @@ class OAuthClientInformationFull(OAuthClientMetadata): class OAuthMetadata(BaseModel): - """ - RFC 8414 OAuth 2.0 Authorization Server Metadata. + """RFC 8414 OAuth 2.0 Authorization Server Metadata. See https://datatracker.ietf.org/doc/html/rfc8414#section-2 """ @@ -136,8 +130,7 @@ class OAuthMetadata(BaseModel): class ProtectedResourceMetadata(BaseModel): - """ - RFC 9728 OAuth 2.0 Protected Resource Metadata. + """RFC 9728 OAuth 2.0 Protected Resource Metadata. See https://datatracker.ietf.org/doc/html/rfc9728#section-2 """ @@ -151,9 +144,9 @@ class ProtectedResourceMetadata(BaseModel): resource_documentation: AnyHttpUrl | None = None resource_policy_uri: AnyHttpUrl | None = None resource_tos_uri: AnyHttpUrl | None = None - # tls_client_certificate_bound_access_tokens default is False, but ommited here for clarity + # tls_client_certificate_bound_access_tokens default is False, but omitted here for clarity tls_client_certificate_bound_access_tokens: bool | None = None authorization_details_types_supported: list[str] | None = None dpop_signing_alg_values_supported: list[str] | None = None - # dpop_bound_access_tokens_required default is False, but ommited here for clarity + # dpop_bound_access_tokens_required default is False, but omitted here for clarity dpop_bound_access_tokens_required: bool | None = None diff --git a/src/mcp/shared/auth_utils.py b/src/mcp/shared/auth_utils.py index 8f3c542f2..3ba880f40 100644 --- a/src/mcp/shared/auth_utils.py +++ b/src/mcp/shared/auth_utils.py @@ -51,22 +51,17 @@ def check_resource_allowed(requested_resource: str, configured_resource: str) -> if requested.scheme.lower() != configured.scheme.lower() or requested.netloc.lower() != configured.netloc.lower(): return False - # Handle cases like requested=/foo and configured=/foo/ + # Normalize trailing slashes before comparison so that + # "/foo" and "/foo/" are treated as equivalent. requested_path = requested.path configured_path = configured.path - - # If requested path is shorter, it cannot be a child - if len(requested_path) < len(configured_path): - return False - - # Check if the requested path starts with the configured path - # Ensure both paths end with / for proper comparison - # This ensures that paths like "/api123" don't incorrectly match "/api" if not requested_path.endswith("/"): requested_path += "/" if not configured_path.endswith("/"): configured_path += "/" + # Check hierarchical match: requested must start with configured path. + # The trailing-slash normalization ensures "/api123/" won't match "/api/". return requested_path.startswith(configured_path) diff --git a/src/mcp/shared/context.py b/src/mcp/shared/context.py deleted file mode 100644 index 5cf6588c9..000000000 --- a/src/mcp/shared/context.py +++ /dev/null @@ -1,32 +0,0 @@ -""" -Request context for MCP handlers. -""" - -from dataclasses import dataclass, field -from typing import Any, Generic - -from typing_extensions import TypeVar - -from mcp.shared.message import CloseSSEStreamCallback -from mcp.shared.session import BaseSession -from mcp.types import RequestId, RequestParams - -SessionT = TypeVar("SessionT", bound=BaseSession[Any, Any, Any, Any, Any]) -LifespanContextT = TypeVar("LifespanContextT") -RequestT = TypeVar("RequestT", default=Any) - - -@dataclass -class RequestContext(Generic[SessionT, LifespanContextT, RequestT]): - request_id: RequestId - meta: RequestParams.Meta | None - session: SessionT - lifespan_context: LifespanContextT - # NOTE: This is typed as Any to avoid circular imports. The actual type is - # mcp.server.experimental.request_context.Experimental, but importing it here - # triggers mcp.server.__init__ -> fastmcp -> tools -> back to this module. - # The Server sets this to an Experimental instance at runtime. - experimental: Any = field(default=None) - request: RequestT | None = None - close_sse_stream: CloseSSEStreamCallback | None = None - close_standalone_sse_stream: CloseSSEStreamCallback | None = None diff --git a/src/mcp/shared/exceptions.py b/src/mcp/shared/exceptions.py index 494311491..f153ea319 100644 --- a/src/mcp/shared/exceptions.py +++ b/src/mcp/shared/exceptions.py @@ -2,57 +2,92 @@ from typing import Any, cast -from mcp.types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams, ErrorData +from mcp.types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams, ErrorData, JSONRPCError -class McpError(Exception): - """ - Exception type raised when an error arrives over an MCP connection. - """ +class MCPError(Exception): + """Exception type raised when an error arrives over an MCP connection.""" error: ErrorData - def __init__(self, error: ErrorData): - """Initialize McpError.""" - super().__init__(error.message) - self.error = error + def __init__(self, code: int, message: str, data: Any = None): + super().__init__(code, message, data) + if data is not None: + self.error = ErrorData(code=code, message=message, data=data) + else: + self.error = ErrorData(code=code, message=message) + + @property + def code(self) -> int: + return self.error.code + + @property + def message(self) -> str: + return self.error.message + + @property + def data(self) -> Any: + return self.error.data # pragma: no cover + + @classmethod + def from_jsonrpc_error(cls, error: JSONRPCError) -> MCPError: + return cls.from_error_data(error.error) + + @classmethod + def from_error_data(cls, error: ErrorData) -> MCPError: + return cls(code=error.code, message=error.message, data=error.data) + + def __str__(self) -> str: + return self.message + +class StatelessModeNotSupported(RuntimeError): + """Raised when attempting to use a method that is not supported in stateless mode. -class UrlElicitationRequiredError(McpError): + Server-to-client requests (sampling, elicitation, list_roots) are not + supported in stateless HTTP mode because there is no persistent connection + for bidirectional communication. """ - Specialized error for when a tool requires URL mode elicitation(s) before proceeding. + + def __init__(self, method: str): + super().__init__( + f"Cannot use {method} in stateless HTTP mode. " + "Stateless mode does not support server-to-client requests. " + "Use stateful mode (stateless_http=False) to enable this feature." + ) + self.method = method + + +class UrlElicitationRequiredError(MCPError): + """Specialized error for when a tool requires URL mode elicitation(s) before proceeding. Servers can raise this error from tool handlers to indicate that the client must complete one or more URL elicitations before the request can be processed. Example: + ```python raise UrlElicitationRequiredError([ ElicitRequestURLParams( - mode="url", message="Authorization required for your files", url="https://example.com/oauth/authorize", - elicitationId="auth-001" + elicitation_id="auth-001" ) ]) + ``` """ - def __init__( - self, - elicitations: list[ElicitRequestURLParams], - message: str | None = None, - ): + def __init__(self, elicitations: list[ElicitRequestURLParams], message: str | None = None): """Initialize UrlElicitationRequiredError.""" if message is None: message = f"URL elicitation{'s' if len(elicitations) > 1 else ''} required" self._elicitations = elicitations - error = ErrorData( + super().__init__( code=URL_ELICITATION_REQUIRED, message=message, data={"elicitations": [e.model_dump(by_alias=True, exclude_none=True) for e in elicitations]}, ) - super().__init__(error) @property def elicitations(self) -> list[ElicitRequestURLParams]: diff --git a/src/mcp/shared/experimental/__init__.py b/src/mcp/shared/experimental/__init__.py index 9b1b1479c..fa6940acc 100644 --- a/src/mcp/shared/experimental/__init__.py +++ b/src/mcp/shared/experimental/__init__.py @@ -1,5 +1,4 @@ -""" -Pure experimental MCP features (no server dependencies). +"""Pure experimental MCP features (no server dependencies). WARNING: These APIs are experimental and may change without notice. diff --git a/src/mcp/shared/experimental/tasks/__init__.py b/src/mcp/shared/experimental/tasks/__init__.py index 37d81af50..52793e408 100644 --- a/src/mcp/shared/experimental/tasks/__init__.py +++ b/src/mcp/shared/experimental/tasks/__init__.py @@ -1,5 +1,4 @@ -""" -Pure task state management for MCP. +"""Pure task state management for MCP. WARNING: These APIs are experimental and may change without notice. diff --git a/src/mcp/shared/experimental/tasks/capabilities.py b/src/mcp/shared/experimental/tasks/capabilities.py index 307fcdd6e..51fe64ecc 100644 --- a/src/mcp/shared/experimental/tasks/capabilities.py +++ b/src/mcp/shared/experimental/tasks/capabilities.py @@ -1,5 +1,4 @@ -""" -Tasks capability checking utilities. +"""Tasks capability checking utilities. This module provides functions for checking and requiring task-related capabilities. All tasks capability logic is centralized here to keep @@ -8,21 +7,15 @@ WARNING: These APIs are experimental and may change without notice. """ -from mcp.shared.exceptions import McpError -from mcp.types import ( - INVALID_REQUEST, - ClientCapabilities, - ClientTasksCapability, - ErrorData, -) +from mcp.shared.exceptions import MCPError +from mcp.types import INVALID_REQUEST, ClientCapabilities, ClientTasksCapability def check_tasks_capability( required: ClientTasksCapability, client: ClientTasksCapability, ) -> bool: - """ - Check if client's tasks capability matches the required capability. + """Check if client's tasks capability matches the required capability. Args: required: The capability being checked for @@ -48,8 +41,8 @@ def check_tasks_capability( if required.requests.sampling is not None: if client.requests.sampling is None: return False - if required.requests.sampling.createMessage is not None: - if client.requests.sampling.createMessage is None: + if required.requests.sampling.create_message is not None: + if client.requests.sampling.create_message is None: return False return True @@ -74,42 +67,30 @@ def has_task_augmented_sampling(caps: ClientCapabilities) -> bool: return False if caps.tasks.requests.sampling is None: return False - return caps.tasks.requests.sampling.createMessage is not None + return caps.tasks.requests.sampling.create_message is not None def require_task_augmented_elicitation(client_caps: ClientCapabilities | None) -> None: - """ - Raise McpError if client doesn't support task-augmented elicitation. + """Raise MCPError if client doesn't support task-augmented elicitation. Args: client_caps: The client's declared capabilities, or None if not initialized Raises: - McpError: If client doesn't support task-augmented elicitation + MCPError: If client doesn't support task-augmented elicitation """ if client_caps is None or not has_task_augmented_elicitation(client_caps): - raise McpError( - ErrorData( - code=INVALID_REQUEST, - message="Client does not support task-augmented elicitation", - ) - ) + raise MCPError(code=INVALID_REQUEST, message="Client does not support task-augmented elicitation") def require_task_augmented_sampling(client_caps: ClientCapabilities | None) -> None: - """ - Raise McpError if client doesn't support task-augmented sampling. + """Raise MCPError if client doesn't support task-augmented sampling. Args: client_caps: The client's declared capabilities, or None if not initialized Raises: - McpError: If client doesn't support task-augmented sampling + MCPError: If client doesn't support task-augmented sampling """ if client_caps is None or not has_task_augmented_sampling(client_caps): - raise McpError( - ErrorData( - code=INVALID_REQUEST, - message="Client does not support task-augmented sampling", - ) - ) + raise MCPError(code=INVALID_REQUEST, message="Client does not support task-augmented sampling") diff --git a/src/mcp/shared/experimental/tasks/context.py b/src/mcp/shared/experimental/tasks/context.py index 12d159515..ed0d2b91b 100644 --- a/src/mcp/shared/experimental/tasks/context.py +++ b/src/mcp/shared/experimental/tasks/context.py @@ -1,5 +1,4 @@ -""" -TaskContext - Pure task state management. +"""TaskContext - Pure task state management. This module provides TaskContext, which manages task state without any server/session dependencies. It can be used standalone for distributed @@ -11,8 +10,7 @@ class TaskContext: - """ - Pure task state management - no session dependencies. + """Pure task state management - no session dependencies. This class handles: - Task state (status, result) @@ -41,7 +39,7 @@ def __init__(self, task: Task, store: TaskStore): @property def task_id(self) -> str: """The task identifier.""" - return self._task.taskId + return self._task.task_id @property def task(self) -> Task: @@ -54,8 +52,7 @@ def is_cancelled(self) -> bool: return self._cancelled def request_cancellation(self) -> None: - """ - Request cancellation of this task. + """Request cancellation of this task. This sets is_cancelled=True. Task work should check this periodically and exit gracefully if set. @@ -63,8 +60,7 @@ def request_cancellation(self) -> None: self._cancelled = True async def update_status(self, message: str) -> None: - """ - Update the task's status message. + """Update the task's status message. Args: message: The new status message @@ -75,8 +71,7 @@ async def update_status(self, message: str) -> None: ) async def complete(self, result: Result) -> None: - """ - Mark the task as completed with the given result. + """Mark the task as completed with the given result. Args: result: The task result @@ -88,8 +83,7 @@ async def complete(self, result: Result) -> None: ) async def fail(self, error: str) -> None: - """ - Mark the task as failed with an error message. + """Mark the task as failed with an error message. Args: error: The error message diff --git a/src/mcp/shared/experimental/tasks/helpers.py b/src/mcp/shared/experimental/tasks/helpers.py index 5c87f9ef8..3f91cd0d0 100644 --- a/src/mcp/shared/experimental/tasks/helpers.py +++ b/src/mcp/shared/experimental/tasks/helpers.py @@ -1,5 +1,4 @@ -""" -Helper functions for pure task management. +"""Helper functions for pure task management. These helpers work with pure TaskContext and don't require server dependencies. For server-integrated task helpers, use mcp.server.experimental. @@ -10,7 +9,7 @@ from datetime import datetime, timezone from uuid import uuid4 -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.shared.experimental.tasks.context import TaskContext from mcp.shared.experimental.tasks.store import TaskStore from mcp.types import ( @@ -20,7 +19,6 @@ TASK_STATUS_FAILED, TASK_STATUS_WORKING, CancelTaskResult, - ErrorData, Task, TaskMetadata, TaskStatus, @@ -36,8 +34,7 @@ def is_terminal(status: TaskStatus) -> bool: - """ - Check if a task status represents a terminal state. + """Check if a task status represents a terminal state. Terminal states are those where the task has finished and will not change. @@ -54,8 +51,7 @@ async def cancel_task( store: TaskStore, task_id: str, ) -> CancelTaskResult: - """ - Cancel a task with spec-compliant validation. + """Cancel a task with spec-compliant validation. Per spec: "Receivers MUST reject cancellation of terminal status tasks with -32602 (Invalid params)" @@ -71,31 +67,22 @@ async def cancel_task( CancelTaskResult with the cancelled task state Raises: - McpError: With INVALID_PARAMS (-32602) if: + MCPError: With INVALID_PARAMS (-32602) if: - Task does not exist - Task is already in a terminal state (completed, failed, cancelled) Example: - @server.experimental.cancel_task() - async def handle_cancel(request: CancelTaskRequest) -> CancelTaskResult: - return await cancel_task(store, request.params.taskId) + ```python + async def handle_cancel(ctx, params: CancelTaskRequestParams) -> CancelTaskResult: + return await cancel_task(store, params.task_id) + ``` """ task = await store.get_task(task_id) if task is None: - raise McpError( - ErrorData( - code=INVALID_PARAMS, - message=f"Task not found: {task_id}", - ) - ) + raise MCPError(code=INVALID_PARAMS, message=f"Task not found: {task_id}") if is_terminal(task.status): - raise McpError( - ErrorData( - code=INVALID_PARAMS, - message=f"Cannot cancel task in terminal state '{task.status}'", - ) - ) + raise MCPError(code=INVALID_PARAMS, message=f"Cannot cancel task in terminal state '{task.status}'") # Update task to cancelled status cancelled_task = await store.update_task(task_id, status=TASK_STATUS_CANCELLED) @@ -111,8 +98,7 @@ def create_task_state( metadata: TaskMetadata, task_id: str | None = None, ) -> Task: - """ - Create a Task object with initial state. + """Create a Task object with initial state. This is a helper for TaskStore implementations. @@ -125,12 +111,12 @@ def create_task_state( """ now = datetime.now(timezone.utc) return Task( - taskId=task_id or generate_task_id(), + task_id=task_id or generate_task_id(), status=TASK_STATUS_WORKING, - createdAt=now, - lastUpdatedAt=now, + created_at=now, + last_updated_at=now, ttl=metadata.ttl, - pollInterval=500, # Default 500ms poll interval + poll_interval=500, # Default 500ms poll interval ) @@ -139,8 +125,7 @@ async def task_execution( task_id: str, store: TaskStore, ) -> AsyncIterator[TaskContext]: - """ - Context manager for safe task execution (pure, no server dependencies). + """Context manager for safe task execution (pure, no server dependencies). Loads a task from the store and provides a TaskContext for the work. If an unhandled exception occurs, the task is automatically marked as failed diff --git a/src/mcp/shared/experimental/tasks/in_memory_task_store.py b/src/mcp/shared/experimental/tasks/in_memory_task_store.py index 7b630ce6e..42f4fb703 100644 --- a/src/mcp/shared/experimental/tasks/in_memory_task_store.py +++ b/src/mcp/shared/experimental/tasks/in_memory_task_store.py @@ -1,5 +1,4 @@ -""" -In-memory implementation of TaskStore for demonstration purposes. +"""In-memory implementation of TaskStore for demonstration purposes. This implementation stores all tasks in memory and provides automatic cleanup based on the TTL duration specified in the task metadata using lazy expiration. @@ -29,8 +28,7 @@ class StoredTask: class InMemoryTaskStore(TaskStore): - """ - A simple in-memory implementation of TaskStore. + """A simple in-memory implementation of TaskStore. Features: - Automatic TTL-based cleanup (lazy expiration) @@ -79,14 +77,14 @@ async def create_task( task = create_task_state(metadata, task_id) - if task.taskId in self._tasks: - raise ValueError(f"Task with ID {task.taskId} already exists") + if task.task_id in self._tasks: + raise ValueError(f"Task with ID {task.task_id} already exists") stored = StoredTask( task=task, expires_at=self._calculate_expiry(metadata.ttl), ) - self._tasks[task.taskId] = stored + self._tasks[task.task_id] = stored # Return a copy to prevent external modification return Task(**task.model_dump()) @@ -124,10 +122,10 @@ async def update_task( status_changed = True if status_message is not None: - stored.task.statusMessage = status_message + stored.task.status_message = status_message - # Update lastUpdatedAt on any change - stored.task.lastUpdatedAt = datetime.now(timezone.utc) + # Update last_updated_at on any change + stored.task.last_updated_at = datetime.now(timezone.utc) # If task is now terminal and has TTL, reset expiry timer if status is not None and is_terminal(status) and stored.task.ttl is not None: diff --git a/src/mcp/shared/experimental/tasks/message_queue.py b/src/mcp/shared/experimental/tasks/message_queue.py index 69b660988..018c2b7b2 100644 --- a/src/mcp/shared/experimental/tasks/message_queue.py +++ b/src/mcp/shared/experimental/tasks/message_queue.py @@ -1,5 +1,4 @@ -""" -TaskMessageQueue - FIFO queue for task-related messages. +"""TaskMessageQueue - FIFO queue for task-related messages. This implements the core message queue pattern from the MCP Tasks spec. When a handler needs to send a request (like elicitation) during a task-augmented @@ -25,8 +24,7 @@ @dataclass class QueuedMessage: - """ - A message queued for delivery via tasks/result. + """A message queued for delivery via tasks/result. Messages are stored with their type and a resolver for requests that expect responses. @@ -49,8 +47,7 @@ class QueuedMessage: class TaskMessageQueue(ABC): - """ - Abstract interface for task message queuing. + """Abstract interface for task message queuing. This is a FIFO queue that stores messages to be delivered via `tasks/result`. When a task-augmented handler calls elicit() or sends a notification, the @@ -65,8 +62,7 @@ class TaskMessageQueue(ABC): @abstractmethod async def enqueue(self, task_id: str, message: QueuedMessage) -> None: - """ - Add a message to the queue for a task. + """Add a message to the queue for a task. Args: task_id: The task identifier @@ -75,8 +71,7 @@ async def enqueue(self, task_id: str, message: QueuedMessage) -> None: @abstractmethod async def dequeue(self, task_id: str) -> QueuedMessage | None: - """ - Remove and return the next message from the queue. + """Remove and return the next message from the queue. Args: task_id: The task identifier @@ -87,8 +82,7 @@ async def dequeue(self, task_id: str) -> QueuedMessage | None: @abstractmethod async def peek(self, task_id: str) -> QueuedMessage | None: - """ - Return the next message without removing it. + """Return the next message without removing it. Args: task_id: The task identifier @@ -99,8 +93,7 @@ async def peek(self, task_id: str) -> QueuedMessage | None: @abstractmethod async def is_empty(self, task_id: str) -> bool: - """ - Check if the queue is empty for a task. + """Check if the queue is empty for a task. Args: task_id: The task identifier @@ -111,8 +104,7 @@ async def is_empty(self, task_id: str) -> bool: @abstractmethod async def clear(self, task_id: str) -> list[QueuedMessage]: - """ - Remove and return all messages from the queue. + """Remove and return all messages from the queue. This is useful for cleanup when a task is cancelled or completed. @@ -125,8 +117,7 @@ async def clear(self, task_id: str) -> list[QueuedMessage]: @abstractmethod async def wait_for_message(self, task_id: str) -> None: - """ - Wait until a message is available in the queue. + """Wait until a message is available in the queue. This blocks until either: 1. A message is enqueued for this task @@ -138,8 +129,7 @@ async def wait_for_message(self, task_id: str) -> None: @abstractmethod async def notify_message_available(self, task_id: str) -> None: - """ - Signal that a message is available for a task. + """Signal that a message is available for a task. This wakes up any coroutines waiting in wait_for_message(). @@ -149,8 +139,7 @@ async def notify_message_available(self, task_id: str) -> None: class InMemoryTaskMessageQueue(TaskMessageQueue): - """ - In-memory implementation of TaskMessageQueue. + """In-memory implementation of TaskMessageQueue. This is suitable for single-process servers. For distributed systems, implement TaskMessageQueue with Redis, RabbitMQ, etc. @@ -227,8 +216,7 @@ async def notify_message_available(self, task_id: str) -> None: self._events[task_id].set() def cleanup(self, task_id: str | None = None) -> None: - """ - Clean up queues and events. + """Clean up queues and events. Args: task_id: If provided, clean up only this task. Otherwise clean up all. diff --git a/src/mcp/shared/experimental/tasks/polling.py b/src/mcp/shared/experimental/tasks/polling.py index 39db2e6b6..e4e13b664 100644 --- a/src/mcp/shared/experimental/tasks/polling.py +++ b/src/mcp/shared/experimental/tasks/polling.py @@ -1,5 +1,4 @@ -""" -Shared polling utilities for task operations. +"""Shared polling utilities for task operations. This module provides generic polling logic that works for both client→server and server→client task polling. @@ -20,8 +19,7 @@ async def poll_until_terminal( task_id: str, default_interval_ms: int = 500, ) -> AsyncIterator[GetTaskResult]: - """ - Poll a task until it reaches terminal status. + """Poll a task until it reaches terminal status. This is a generic utility that works for both client→server and server→client polling. The caller provides the get_task function appropriate for their direction. @@ -41,5 +39,5 @@ async def poll_until_terminal( if is_terminal(status.status): break - interval_ms = status.pollInterval if status.pollInterval is not None else default_interval_ms + interval_ms = status.poll_interval if status.poll_interval is not None else default_interval_ms await anyio.sleep(interval_ms / 1000) diff --git a/src/mcp/shared/experimental/tasks/resolver.py b/src/mcp/shared/experimental/tasks/resolver.py index f27425b2c..1d233a930 100644 --- a/src/mcp/shared/experimental/tasks/resolver.py +++ b/src/mcp/shared/experimental/tasks/resolver.py @@ -1,5 +1,4 @@ -""" -Resolver - An anyio-compatible future-like object for async result passing. +"""Resolver - An anyio-compatible future-like object for async result passing. This provides a simple way to pass a result (or exception) from one coroutine to another without depending on asyncio.Future. @@ -13,8 +12,7 @@ class Resolver(Generic[T]): - """ - A simple resolver for passing results between coroutines. + """A simple resolver for passing results between coroutines. Unlike asyncio.Future, this works with any anyio-compatible async backend. diff --git a/src/mcp/shared/experimental/tasks/store.py b/src/mcp/shared/experimental/tasks/store.py index 71fb4511b..7de97d40c 100644 --- a/src/mcp/shared/experimental/tasks/store.py +++ b/src/mcp/shared/experimental/tasks/store.py @@ -1,6 +1,4 @@ -""" -TaskStore - Abstract interface for task state storage. -""" +"""TaskStore - Abstract interface for task state storage.""" from abc import ABC, abstractmethod @@ -8,8 +6,7 @@ class TaskStore(ABC): - """ - Abstract interface for task state storage. + """Abstract interface for task state storage. This is a pure storage interface - it doesn't manage execution. Implementations can use in-memory storage, databases, Redis, etc. @@ -23,8 +20,7 @@ async def create_task( metadata: TaskMetadata, task_id: str | None = None, ) -> Task: - """ - Create a new task. + """Create a new task. Args: metadata: Task metadata (ttl, etc.) @@ -39,8 +35,7 @@ async def create_task( @abstractmethod async def get_task(self, task_id: str) -> Task | None: - """ - Get a task by ID. + """Get a task by ID. Args: task_id: The task identifier @@ -56,8 +51,7 @@ async def update_task( status: TaskStatus | None = None, status_message: str | None = None, ) -> Task: - """ - Update a task's status and/or message. + """Update a task's status and/or message. Args: task_id: The task identifier @@ -76,8 +70,7 @@ async def update_task( @abstractmethod async def store_result(self, task_id: str, result: Result) -> None: - """ - Store the result for a task. + """Store the result for a task. Args: task_id: The task identifier @@ -89,8 +82,7 @@ async def store_result(self, task_id: str, result: Result) -> None: @abstractmethod async def get_result(self, task_id: str) -> Result | None: - """ - Get the stored result for a task. + """Get the stored result for a task. Args: task_id: The task identifier @@ -104,8 +96,7 @@ async def list_tasks( self, cursor: str | None = None, ) -> tuple[list[Task], str | None]: - """ - List tasks with pagination. + """List tasks with pagination. Args: cursor: Optional cursor for pagination @@ -116,8 +107,7 @@ async def list_tasks( @abstractmethod async def delete_task(self, task_id: str) -> bool: - """ - Delete a task. + """Delete a task. Args: task_id: The task identifier @@ -128,8 +118,7 @@ async def delete_task(self, task_id: str) -> bool: @abstractmethod async def wait_for_update(self, task_id: str) -> None: - """ - Wait until the task status changes. + """Wait until the task status changes. This blocks until either: 1. The task status changes @@ -146,8 +135,7 @@ async def wait_for_update(self, task_id: str) -> None: @abstractmethod async def notify_update(self, task_id: str) -> None: - """ - Signal that a task has been updated. + """Signal that a task has been updated. This wakes up any coroutines waiting in wait_for_update(). diff --git a/src/mcp/shared/memory.py b/src/mcp/shared/memory.py index 06d404e31..f2d5e2b9a 100644 --- a/src/mcp/shared/memory.py +++ b/src/mcp/shared/memory.py @@ -1,21 +1,13 @@ -""" -In-memory transports -""" +"""In-memory transports""" from __future__ import annotations from collections.abc import AsyncGenerator from contextlib import asynccontextmanager -from datetime import timedelta -from typing import Any import anyio from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -import mcp.types as types -from mcp.client.session import ClientSession, ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT -from mcp.server import Server -from mcp.server.fastmcp import FastMCP from mcp.shared.message import SessionMessage MessageStream = tuple[MemoryObjectReceiveStream[SessionMessage | Exception], MemoryObjectSendStream[SessionMessage]] @@ -23,10 +15,9 @@ @asynccontextmanager async def create_client_server_memory_streams() -> AsyncGenerator[tuple[MessageStream, MessageStream], None]: - """ - Creates a pair of bidirectional memory streams for client-server communication. + """Creates a pair of bidirectional memory streams for client-server communication. - Returns: + Yields: A tuple of (client_streams, server_streams) where each is a tuple of (read_stream, write_stream) """ @@ -37,62 +28,5 @@ async def create_client_server_memory_streams() -> AsyncGenerator[tuple[MessageS client_streams = (server_to_client_receive, client_to_server_send) server_streams = (client_to_server_receive, server_to_client_send) - async with ( - server_to_client_receive, - client_to_server_send, - client_to_server_receive, - server_to_client_send, - ): + async with server_to_client_receive, client_to_server_send, client_to_server_receive, server_to_client_send: yield client_streams, server_streams - - -@asynccontextmanager -async def create_connected_server_and_client_session( - server: Server[Any] | FastMCP, - read_timeout_seconds: timedelta | None = None, - sampling_callback: SamplingFnT | None = None, - list_roots_callback: ListRootsFnT | None = None, - logging_callback: LoggingFnT | None = None, - message_handler: MessageHandlerFnT | None = None, - client_info: types.Implementation | None = None, - raise_exceptions: bool = False, - elicitation_callback: ElicitationFnT | None = None, -) -> AsyncGenerator[ClientSession, None]: - """Creates a ClientSession that is connected to a running MCP server.""" - - # TODO(Marcelo): we should have a proper `Client` that can use this "in-memory transport", - # and we should expose a method in the `FastMCP` so we don't access a private attribute. - if isinstance(server, FastMCP): # pragma: no cover - server = server._mcp_server # type: ignore[reportPrivateUsage] - - async with create_client_server_memory_streams() as (client_streams, server_streams): - client_read, client_write = client_streams - server_read, server_write = server_streams - - # Create a cancel scope for the server task - async with anyio.create_task_group() as tg: - tg.start_soon( - lambda: server.run( - server_read, - server_write, - server.create_initialization_options(), - raise_exceptions=raise_exceptions, - ) - ) - - try: - async with ClientSession( - read_stream=client_read, - write_stream=client_write, - read_timeout_seconds=read_timeout_seconds, - sampling_callback=sampling_callback, - list_roots_callback=list_roots_callback, - logging_callback=logging_callback, - message_handler=message_handler, - client_info=client_info, - elicitation_callback=elicitation_callback, - ) as client_session: - await client_session.initialize() - yield client_session - finally: # pragma: no cover - tg.cancel_scope.cancel() diff --git a/src/mcp/shared/message.py b/src/mcp/shared/message.py index 81503eaaa..1858eeac3 100644 --- a/src/mcp/shared/message.py +++ b/src/mcp/shared/message.py @@ -1,5 +1,4 @@ -""" -Message wrapper with metadata support. +"""Message wrapper with metadata support. This module defines a wrapper type that combines JSONRPCMessage with metadata to support transport-specific features like resumability. @@ -7,6 +6,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass +from typing import Any from mcp.types import JSONRPCMessage, RequestId @@ -31,8 +31,10 @@ class ServerMessageMetadata: """Metadata specific to server messages.""" related_request_id: RequestId | None = None - # Request-specific context (e.g., headers, auth info) - request_context: object | None = None + # Transport-specific request context (e.g. starlette Request for HTTP + # transports, None for stdio). Typed as Any because the server layer is + # transport-agnostic. + request_context: Any = None # Callback to close SSE stream for the current request without terminating close_sse_stream: CloseSSEStreamCallback | None = None # Callback to close the standalone GET SSE stream (for unsolicited notifications) diff --git a/src/mcp/shared/metadata_utils.py b/src/mcp/shared/metadata_utils.py index e3f49daf4..6e4d33da0 100644 --- a/src/mcp/shared/metadata_utils.py +++ b/src/mcp/shared/metadata_utils.py @@ -1,15 +1,14 @@ """Utility functions for working with metadata in MCP types. These utilities are primarily intended for client-side usage to properly display -human-readable names in user interfaces in a spec compliant way. +human-readable names in user interfaces in a spec-compliant way. """ from mcp.types import Implementation, Prompt, Resource, ResourceTemplate, Tool def get_display_name(obj: Tool | Resource | Prompt | ResourceTemplate | Implementation) -> str: - """ - Get the display name for an MCP object with proper precedence. + """Get the display name for an MCP object with proper precedence. This is a client-side utility function designed to help MCP clients display human-readable names in their user interfaces. When servers provide a 'title' @@ -19,11 +18,13 @@ def get_display_name(obj: Tool | Resource | Prompt | ResourceTemplate | Implemen For other objects: title > name Example: + ```python # In a client displaying available tools tools = await session.list_tools() for tool in tools.tools: display_name = get_display_name(tool) print(f"Available tool: {display_name}") + ``` Args: obj: An MCP object with name and optional title fields diff --git a/src/mcp/shared/progress.py b/src/mcp/shared/progress.py deleted file mode 100644 index a230c58b4..000000000 --- a/src/mcp/shared/progress.py +++ /dev/null @@ -1,58 +0,0 @@ -from collections.abc import Generator -from contextlib import contextmanager -from dataclasses import dataclass, field -from typing import Generic - -from pydantic import BaseModel - -from mcp.shared.context import LifespanContextT, RequestContext -from mcp.shared.session import ( - BaseSession, - ReceiveNotificationT, - ReceiveRequestT, - SendNotificationT, - SendRequestT, - SendResultT, -) -from mcp.types import ProgressToken - - -class Progress(BaseModel): - progress: float - total: float | None - - -@dataclass -class ProgressContext(Generic[SendRequestT, SendNotificationT, SendResultT, ReceiveRequestT, ReceiveNotificationT]): - session: BaseSession[SendRequestT, SendNotificationT, SendResultT, ReceiveRequestT, ReceiveNotificationT] - progress_token: ProgressToken - total: float | None - current: float = field(default=0.0, init=False) - - async def progress(self, amount: float, message: str | None = None) -> None: - self.current += amount - - await self.session.send_progress_notification( - self.progress_token, self.current, total=self.total, message=message - ) - - -@contextmanager -def progress( - ctx: RequestContext[ - BaseSession[SendRequestT, SendNotificationT, SendResultT, ReceiveRequestT, ReceiveNotificationT], - LifespanContextT, - ], - total: float | None = None, -) -> Generator[ - ProgressContext[SendRequestT, SendNotificationT, SendResultT, ReceiveRequestT, ReceiveNotificationT], - None, -]: - if ctx.meta is None or ctx.meta.progressToken is None: # pragma: no cover - raise ValueError("No progress token provided") - - progress_ctx = ProgressContext(ctx.session, ctx.meta.progressToken, total) - try: - yield progress_ctx - finally: - pass diff --git a/src/mcp/shared/response_router.py b/src/mcp/shared/response_router.py index 31796157f..fe24b016f 100644 --- a/src/mcp/shared/response_router.py +++ b/src/mcp/shared/response_router.py @@ -1,5 +1,4 @@ -""" -ResponseRouter - Protocol for pluggable response routing. +"""ResponseRouter - Protocol for pluggable response routing. This module defines a protocol for routing JSON-RPC responses to alternative handlers before falling back to the default response stream mechanism. @@ -20,13 +19,13 @@ class ResponseRouter(Protocol): - """ - Protocol for routing responses to alternative handlers. + """Protocol for routing responses to alternative handlers. Implementations check if they have a pending request for the given ID and deliver the response/error to the appropriate handler. Example: + ```python class TaskResultHandler(ResponseRouter): def route_response(self, request_id, response): resolver = self._pending_requests.pop(request_id, None) @@ -34,11 +33,11 @@ def route_response(self, request_id, response): resolver.set_result(response) return True return False + ``` """ def route_response(self, request_id: RequestId, response: dict[str, Any]) -> bool: - """ - Try to route a response to a pending request handler. + """Try to route a response to a pending request handler. Args: request_id: The JSON-RPC request ID from the response @@ -50,8 +49,7 @@ def route_response(self, request_id: RequestId, response: dict[str, Any]) -> boo ... # pragma: no cover def route_error(self, request_id: RequestId, error: ErrorData) -> bool: - """ - Try to route an error to a pending request handler. + """Try to route an error to a pending request handler. Args: request_id: The JSON-RPC request ID from the error response diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index cceefccce..b617d702f 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -1,34 +1,35 @@ +from __future__ import annotations + import logging from collections.abc import Callable from contextlib import AsyncExitStack -from datetime import timedelta from types import TracebackType from typing import Any, Generic, Protocol, TypeVar import anyio -import httpx from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from pydantic import BaseModel +from pydantic import BaseModel, TypeAdapter from typing_extensions import Self -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMessage from mcp.shared.response_router import ResponseRouter from mcp.types import ( CONNECTION_CLOSED, INVALID_PARAMS, + REQUEST_TIMEOUT, CancelledNotification, ClientNotification, ClientRequest, ClientResult, ErrorData, JSONRPCError, - JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse, ProgressNotification, - RequestParams, + ProgressToken, + RequestParamsMeta, ServerNotification, ServerRequest, ServerResult, @@ -59,8 +60,10 @@ class RequestResponder(Generic[ReceiveRequestT, SendResultT]): cancellation handling: Example: + ```python with request_responder as resp: await resp.respond(result) + ``` The context manager ensures: 1. Proper cancellation scope setup and cleanup @@ -71,16 +74,10 @@ class RequestResponder(Generic[ReceiveRequestT, SendResultT]): def __init__( self, request_id: RequestId, - request_meta: RequestParams.Meta | None, + request_meta: RequestParamsMeta | None, request: ReceiveRequestT, - session: """BaseSession[ - SendRequestT, - SendNotificationT, - SendResultT, - ReceiveRequestT, - ReceiveNotificationT - ]""", - on_complete: Callable[["RequestResponder[ReceiveRequestT, SendResultT]"], Any], + session: BaseSession[SendRequestT, SendNotificationT, SendResultT, ReceiveRequestT, ReceiveNotificationT], + on_complete: Callable[[RequestResponder[ReceiveRequestT, SendResultT]], Any], message_metadata: MessageMetadata = None, ) -> None: self.request_id = request_id @@ -93,7 +90,7 @@ def __init__( self._on_complete = on_complete self._entered = False # Track if we're in a context manager - def __enter__(self) -> "RequestResponder[ReceiveRequestT, SendResultT]": + def __enter__(self) -> RequestResponder[ReceiveRequestT, SendResultT]: """Enter the context manager, enabling request cancellation tracking.""" self._entered = True self._cancel_scope = anyio.CancelScope() @@ -120,6 +117,7 @@ async def respond(self, response: SendResultT | ErrorData) -> None: """Send a response for this request. Must be called within a context manager block. + Raises: RuntimeError: If not used within a context manager AssertionError: If request was already responded to @@ -147,7 +145,7 @@ async def cancel(self) -> None: # Send an error response to indicate cancellation await self._session._send_response( # type: ignore[reportPrivateUsage] request_id=self.request_id, - response=ErrorData(code=0, message="Request cancelled", data=None), + response=ErrorData(code=0, message="Request cancelled"), ) @property @@ -155,7 +153,7 @@ def in_flight(self) -> bool: # pragma: no cover return not self._completed and not self.cancelled @property - def cancelled(self) -> bool: # pragma: no cover + def cancelled(self) -> bool: return self._cancel_scope.cancel_called @@ -168,8 +166,7 @@ class BaseSession( ReceiveNotificationT, ], ): - """ - Implements an MCP "session" on top of read/write streams, including features + """Implements an MCP "session" on top of read/write streams, including features like request/response linking, notifications, and progress. This class is an async context manager that automatically starts processing @@ -180,23 +177,19 @@ class BaseSession( _request_id: int _in_flight: dict[RequestId, RequestResponder[ReceiveRequestT, SendResultT]] _progress_callbacks: dict[RequestId, ProgressFnT] - _response_routers: list["ResponseRouter"] + _response_routers: list[ResponseRouter] def __init__( self, read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], write_stream: MemoryObjectSendStream[SessionMessage], - receive_request_type: type[ReceiveRequestT], - receive_notification_type: type[ReceiveNotificationT], # If none, reading will never time out - read_timeout_seconds: timedelta | None = None, + read_timeout_seconds: float | None = None, ) -> None: self._read_stream = read_stream self._write_stream = write_stream self._response_streams = {} self._request_id = 0 - self._receive_request_type = receive_request_type - self._receive_notification_type = receive_notification_type self._session_read_timeout_seconds = read_timeout_seconds self._in_flight = {} self._progress_callbacks = {} @@ -204,14 +197,14 @@ def __init__( self._exit_stack = AsyncExitStack() def add_response_router(self, router: ResponseRouter) -> None: - """ - Register a response router to handle responses for non-standard requests. + """Register a response router to handle responses for non-standard requests. Response routers are checked in order before falling back to the default response stream mechanism. This is used by TaskResultHandler to route responses for queued task requests back to their resolvers. - WARNING: This is an experimental API that may change without notice. + !!! warning + This is an experimental API that may change without notice. Args: router: A ResponseRouter implementation @@ -241,17 +234,16 @@ async def send_request( self, request: SendRequestT, result_type: type[ReceiveResultT], - request_read_timeout_seconds: timedelta | None = None, + request_read_timeout_seconds: float | None = None, metadata: MessageMetadata = None, progress_callback: ProgressFnT | None = None, ) -> ReceiveResultT: - """ - Sends a request and wait for a response. Raises an McpError if the - response contains an error. If a request read timeout is provided, it - will take precedence over the session read timeout. + """Sends a request and waits for a response. + + Raises an MCPError if the response contains an error. If a request read timeout is provided, it will take + precedence over the session read timeout. - Do not use this method to emit notifications! Use send_notification() - instead. + Do not use this method to emit notifications! Use send_notification() instead. """ request_id = self._request_id self._request_id = request_id + 1 @@ -261,51 +253,35 @@ async def send_request( # Set up progress token if progress callback is provided request_data = request.model_dump(by_alias=True, mode="json", exclude_none=True) - if progress_callback is not None: # pragma: no cover + if progress_callback is not None: # Use request_id as progress token - if "params" not in request_data: + if "params" not in request_data: # pragma: lax no cover request_data["params"] = {} - if "_meta" not in request_data["params"]: # pragma: no branch + if "_meta" not in request_data["params"]: # pragma: lax no cover request_data["params"]["_meta"] = {} request_data["params"]["_meta"]["progressToken"] = request_id # Store the callback for this request self._progress_callbacks[request_id] = progress_callback try: - jsonrpc_request = JSONRPCRequest( - jsonrpc="2.0", - id=request_id, - **request_data, - ) - - await self._write_stream.send(SessionMessage(message=JSONRPCMessage(jsonrpc_request), metadata=metadata)) + jsonrpc_request = JSONRPCRequest(jsonrpc="2.0", id=request_id, **request_data) + await self._write_stream.send(SessionMessage(message=jsonrpc_request, metadata=metadata)) # request read timeout takes precedence over session read timeout - timeout = None - if request_read_timeout_seconds is not None: # pragma: no cover - timeout = request_read_timeout_seconds.total_seconds() - elif self._session_read_timeout_seconds is not None: # pragma: no cover - timeout = self._session_read_timeout_seconds.total_seconds() + timeout = request_read_timeout_seconds or self._session_read_timeout_seconds try: with anyio.fail_after(timeout): response_or_error = await response_stream_reader.receive() except TimeoutError: - raise McpError( - ErrorData( - code=httpx.codes.REQUEST_TIMEOUT, - message=( - f"Timed out while waiting for response to " - f"{request.__class__.__name__}. Waited " - f"{timeout} seconds." - ), - ) - ) + class_name = request.__class__.__name__ + message = f"Timed out while waiting for response to {class_name}. Waited {timeout} seconds." + raise MCPError(code=REQUEST_TIMEOUT, message=message) if isinstance(response_or_error, JSONRPCError): - raise McpError(response_or_error.error) + raise MCPError.from_jsonrpc_error(response_or_error) else: - return result_type.model_validate(response_or_error.result) + return result_type.model_validate(response_or_error.result, by_name=False) finally: self._response_streams.pop(request_id, None) @@ -318,18 +294,15 @@ async def send_notification( notification: SendNotificationT, related_request_id: RequestId | None = None, ) -> None: - """ - Emits a notification, which is a one-way message that does not expect - a response. - """ + """Emits a notification, which is a one-way message that does not expect a response.""" # Some transport implementations may need to set the related_request_id # to attribute to the notifications to the request that triggered them. jsonrpc_notification = JSONRPCNotification( jsonrpc="2.0", **notification.model_dump(by_alias=True, mode="json", exclude_none=True), ) - session_message = SessionMessage( # pragma: no cover - message=JSONRPCMessage(jsonrpc_notification), + session_message = SessionMessage( + message=jsonrpc_notification, metadata=ServerMessageMetadata(related_request_id=related_request_id) if related_request_id else None, ) await self._write_stream.send(session_message) @@ -337,7 +310,7 @@ async def send_notification( async def _send_response(self, request_id: RequestId, response: SendResultT | ErrorData) -> None: if isinstance(response, ErrorData): jsonrpc_error = JSONRPCError(jsonrpc="2.0", id=request_id, error=response) - session_message = SessionMessage(message=JSONRPCMessage(jsonrpc_error)) + session_message = SessionMessage(message=jsonrpc_error) await self._write_stream.send(session_message) else: jsonrpc_response = JSONRPCResponse( @@ -345,28 +318,33 @@ async def _send_response(self, request_id: RequestId, response: SendResultT | Er id=request_id, result=response.model_dump(by_alias=True, mode="json", exclude_none=True), ) - session_message = SessionMessage(message=JSONRPCMessage(jsonrpc_response)) + session_message = SessionMessage(message=jsonrpc_response) await self._write_stream.send(session_message) + @property + def _receive_request_adapter(self) -> TypeAdapter[ReceiveRequestT]: + """Each subclass must provide its own request adapter.""" + raise NotImplementedError + + @property + def _receive_notification_adapter(self) -> TypeAdapter[ReceiveNotificationT]: + raise NotImplementedError + async def _receive_loop(self) -> None: - async with ( - self._read_stream, - self._write_stream, - ): + async with self._read_stream, self._write_stream: try: async for message in self._read_stream: if isinstance(message, Exception): # pragma: no cover await self._handle_incoming(message) - elif isinstance(message.message.root, JSONRPCRequest): + elif isinstance(message.message, JSONRPCRequest): try: - validated_request = self._receive_request_type.model_validate( - message.message.root.model_dump(by_alias=True, mode="json", exclude_none=True) + validated_request = self._receive_request_adapter.validate_python( + message.message.model_dump(by_alias=True, mode="json", exclude_none=True), + by_name=False, ) responder = RequestResponder( - request_id=message.message.root.id, - request_meta=validated_request.root.params.meta - if validated_request.root.params - else None, + request_id=message.message.id, + request_meta=validated_request.params.meta if validated_request.params else None, request=validated_request, session=self, on_complete=lambda r: self._in_flight.pop(r.request_id, None), @@ -377,58 +355,53 @@ async def _receive_loop(self) -> None: if not responder._completed: # type: ignore[reportPrivateUsage] await self._handle_incoming(responder) - except Exception as e: + except Exception: # For request validation errors, send a proper JSON-RPC error # response instead of crashing the server - logging.warning(f"Failed to validate request: {e}") - logging.debug(f"Message that failed validation: {message.message.root}") + logging.warning("Failed to validate request", exc_info=True) + logging.debug(f"Message that failed validation: {message.message}") error_response = JSONRPCError( jsonrpc="2.0", - id=message.message.root.id, - error=ErrorData( - code=INVALID_PARAMS, - message="Invalid request parameters", - data="", - ), + id=message.message.id, + error=ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data=""), ) - session_message = SessionMessage(message=JSONRPCMessage(error_response)) + session_message = SessionMessage(message=error_response) await self._write_stream.send(session_message) - elif isinstance(message.message.root, JSONRPCNotification): + elif isinstance(message.message, JSONRPCNotification): try: - notification = self._receive_notification_type.model_validate( - message.message.root.model_dump(by_alias=True, mode="json", exclude_none=True) + notification = self._receive_notification_adapter.validate_python( + message.message.model_dump(by_alias=True, mode="json", exclude_none=True), + by_name=False, ) # Handle cancellation notifications - if isinstance(notification.root, CancelledNotification): - cancelled_id = notification.root.params.requestId + if isinstance(notification, CancelledNotification): + cancelled_id = notification.params.request_id if cancelled_id in self._in_flight: # pragma: no branch await self._in_flight[cancelled_id].cancel() else: # Handle progress notifications callback - if isinstance(notification.root, ProgressNotification): # pragma: no cover - progress_token = notification.root.params.progressToken + if isinstance(notification, ProgressNotification): + progress_token = notification.params.progress_token # If there is a progress callback for this token, # call it with the progress information if progress_token in self._progress_callbacks: callback = self._progress_callbacks[progress_token] try: await callback( - notification.root.params.progress, - notification.root.params.total, - notification.root.params.message, - ) - except Exception as e: - logging.error( - "Progress callback raised an exception: %s", - e, + notification.params.progress, + notification.params.total, + notification.params.message, ) + except Exception: + logging.exception("Progress callback raised an exception") await self._received_notification(notification) await self._handle_incoming(notification) - except Exception as e: # pragma: no cover + except Exception: # For other validation errors, log and continue - logging.warning( - f"Failed to validate notification: {e}. Message was: {message.message.root}" + logging.warning( # pragma: no cover + f"Failed to validate notification:. Message was: {message.message}", + exc_info=True, ) else: # Response or error await self._handle_response(message) @@ -437,11 +410,11 @@ async def _receive_loop(self) -> None: # This is expected when the client disconnects abruptly. # Without this handler, the exception would propagate up and # crash the server's task group. - logging.debug("Read stream closed by client") # pragma: no cover - except Exception as e: # pragma: no cover + logging.debug("Read stream closed by client") + except Exception as e: # Other exceptions are not expected and should be logged. We purposefully # catch all exceptions here to avoid crashing the server. - logging.exception(f"Unhandled exception in receive loop: {e}") + logging.exception(f"Unhandled exception in receive loop: {e}") # pragma: no cover finally: # after the read stream is closed, we need to send errors # to any pending requests @@ -455,47 +428,70 @@ async def _receive_loop(self) -> None: pass self._response_streams.clear() - async def _handle_response(self, message: SessionMessage) -> None: + def _normalize_request_id(self, response_id: RequestId) -> RequestId: + """Normalize a response ID to match how request IDs are stored. + + Since the client always sends integer IDs, we normalize string IDs + to integers when possible. This matches the TypeScript SDK approach: + https://github.com/modelcontextprotocol/typescript-sdk/blob/a606fb17909ea454e83aab14c73f14ea45c04448/src/shared/protocol.ts#L861 + + Args: + response_id: The response ID from the incoming message. + + Returns: + The normalized ID (int if possible, otherwise original value). """ - Handle an incoming response or error message. + if isinstance(response_id, str): + try: + return int(response_id) + except ValueError: + logging.warning(f"Response ID {response_id!r} cannot be normalized to match pending requests") + return response_id + + async def _handle_response(self, message: SessionMessage) -> None: + """Handle an incoming response or error message. Checks response routers first (e.g., for task-related responses), then falls back to the normal response stream mechanism. """ - root = message.message.root - # This check is always true at runtime: the caller (_receive_loop) only invokes # this method in the else branch after checking for JSONRPCRequest and # JSONRPCNotification. However, the type checker can't infer this from the # method signature, so we need this guard for type narrowing. - if not isinstance(root, JSONRPCResponse | JSONRPCError): + if not isinstance(message.message, JSONRPCResponse | JSONRPCError): return # pragma: no cover - response_id: RequestId = root.id + if message.message.id is None: + # Narrows to JSONRPCError since JSONRPCResponse.id is always RequestId + error = message.message.error + logging.warning(f"Received error with null ID: {error.message}") + await self._handle_incoming(MCPError(error.code, error.message, error.data)) + return + # Normalize response ID to handle type mismatches (e.g., "0" vs 0) + response_id = self._normalize_request_id(message.message.id) # First, check response routers (e.g., TaskResultHandler) - if isinstance(root, JSONRPCError): + if isinstance(message.message, JSONRPCError): # Route error to routers for router in self._response_routers: - if router.route_error(response_id, root.error): + if router.route_error(response_id, message.message.error): return # Handled else: # Route success response to routers - response_data: dict[str, Any] = root.result or {} + response_data: dict[str, Any] = message.message.result or {} for router in self._response_routers: if router.route_response(response_id, response_data): return # Handled # Fall back to normal response streams stream = self._response_streams.pop(response_id, None) - if stream: # pragma: no cover - await stream.send(root) - else: # pragma: no cover + if stream: + await stream.send(message.message) + else: await self._handle_incoming(RuntimeError(f"Received response with an unknown request ID: {message}")) async def _received_request(self, responder: RequestResponder[ReceiveRequestT, SendResultT]) -> None: - """ - Can be overridden by subclasses to handle a request without needing to + """Can be overridden by subclasses to handle a request without needing to listen on the message stream. If the request is responded to within this method, it will not be @@ -503,26 +499,20 @@ async def _received_request(self, responder: RequestResponder[ReceiveRequestT, S """ async def _received_notification(self, notification: ReceiveNotificationT) -> None: - """ - Can be overridden by subclasses to handle a notification without needing + """Can be overridden by subclasses to handle a notification without needing to listen on the message stream. """ async def send_progress_notification( self, - progress_token: str | int, + progress_token: ProgressToken, progress: float, total: float | None = None, message: str | None = None, ) -> None: - """ - Sends a progress notification for a request that is currently being - processed. - """ + """Sends a progress notification for a request that is currently being processed.""" async def _handle_incoming( - self, - req: RequestResponder[ReceiveRequestT, SendResultT] | ReceiveNotificationT | Exception, + self, req: RequestResponder[ReceiveRequestT, SendResultT] | ReceiveNotificationT | Exception ) -> None: - """A generic handler for incoming messages. Overwritten by subclasses.""" - pass # pragma: no cover + """A generic handler for incoming messages. Overridden by subclasses.""" diff --git a/src/mcp/types/__init__.py b/src/mcp/types/__init__.py new file mode 100644 index 000000000..b44230393 --- /dev/null +++ b/src/mcp/types/__init__.py @@ -0,0 +1,414 @@ +"""This module defines the types for the MCP protocol. + +Check the latest schema at: +https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-11-25/schema.json +""" + +# Re-export everything from _types for backward compatibility +from mcp.types._types import ( + DEFAULT_NEGOTIATED_VERSION, + LATEST_PROTOCOL_VERSION, + TASK_FORBIDDEN, + TASK_OPTIONAL, + TASK_REQUIRED, + TASK_STATUS_CANCELLED, + TASK_STATUS_COMPLETED, + TASK_STATUS_FAILED, + TASK_STATUS_INPUT_REQUIRED, + TASK_STATUS_WORKING, + Annotations, + AudioContent, + BaseMetadata, + BlobResourceContents, + CallToolRequest, + CallToolRequestParams, + CallToolResult, + CancelledNotification, + CancelledNotificationParams, + CancelTaskRequest, + CancelTaskRequestParams, + CancelTaskResult, + ClientCapabilities, + ClientNotification, + ClientRequest, + ClientResult, + ClientTasksCapability, + ClientTasksRequestsCapability, + CompleteRequest, + CompleteRequestParams, + CompleteResult, + Completion, + CompletionArgument, + CompletionContext, + CompletionsCapability, + ContentBlock, + CreateMessageRequest, + CreateMessageRequestParams, + CreateMessageResult, + CreateMessageResultWithTools, + CreateTaskResult, + ElicitationCapability, + ElicitationRequiredErrorData, + ElicitCompleteNotification, + ElicitCompleteNotificationParams, + ElicitRequest, + ElicitRequestedSchema, + ElicitRequestFormParams, + ElicitRequestParams, + ElicitRequestURLParams, + ElicitResult, + EmbeddedResource, + EmptyResult, + FormElicitationCapability, + GetPromptRequest, + GetPromptRequestParams, + GetPromptResult, + GetTaskPayloadRequest, + GetTaskPayloadRequestParams, + GetTaskPayloadResult, + GetTaskRequest, + GetTaskRequestParams, + GetTaskResult, + Icon, + IconTheme, + ImageContent, + Implementation, + IncludeContext, + InitializedNotification, + InitializeRequest, + InitializeRequestParams, + InitializeResult, + ListPromptsRequest, + ListPromptsResult, + ListResourcesRequest, + ListResourcesResult, + ListResourceTemplatesRequest, + ListResourceTemplatesResult, + ListRootsRequest, + ListRootsResult, + ListTasksRequest, + ListTasksResult, + ListToolsRequest, + ListToolsResult, + LoggingCapability, + LoggingLevel, + LoggingMessageNotification, + LoggingMessageNotificationParams, + ModelHint, + ModelPreferences, + Notification, + NotificationParams, + PaginatedRequest, + PaginatedRequestParams, + PaginatedResult, + PingRequest, + ProgressNotification, + ProgressNotificationParams, + ProgressToken, + Prompt, + PromptArgument, + PromptListChangedNotification, + PromptMessage, + PromptReference, + PromptsCapability, + ReadResourceRequest, + ReadResourceRequestParams, + ReadResourceResult, + RelatedTaskMetadata, + Request, + RequestParams, + RequestParamsMeta, + Resource, + ResourceContents, + ResourceLink, + ResourceListChangedNotification, + ResourcesCapability, + ResourceTemplate, + ResourceTemplateReference, + ResourceUpdatedNotification, + ResourceUpdatedNotificationParams, + Result, + Role, + Root, + RootsCapability, + RootsListChangedNotification, + SamplingCapability, + SamplingContent, + SamplingContextCapability, + SamplingMessage, + SamplingMessageContentBlock, + SamplingToolsCapability, + ServerCapabilities, + ServerNotification, + ServerRequest, + ServerResult, + ServerTasksCapability, + ServerTasksRequestsCapability, + SetLevelRequest, + SetLevelRequestParams, + StopReason, + SubscribeRequest, + SubscribeRequestParams, + Task, + TaskExecutionMode, + TaskMetadata, + TasksCallCapability, + TasksCancelCapability, + TasksCreateElicitationCapability, + TasksCreateMessageCapability, + TasksElicitationCapability, + TasksListCapability, + TasksSamplingCapability, + TaskStatus, + TaskStatusNotification, + TaskStatusNotificationParams, + TasksToolsCapability, + TextContent, + TextResourceContents, + Tool, + ToolAnnotations, + ToolChoice, + ToolExecution, + ToolListChangedNotification, + ToolResultContent, + ToolsCapability, + ToolUseContent, + UnsubscribeRequest, + UnsubscribeRequestParams, + UrlElicitationCapability, + client_notification_adapter, + client_request_adapter, + client_result_adapter, + server_notification_adapter, + server_request_adapter, + server_result_adapter, +) + +# Re-export JSONRPC types +from mcp.types.jsonrpc import ( + CONNECTION_CLOSED, + INTERNAL_ERROR, + INVALID_PARAMS, + INVALID_REQUEST, + METHOD_NOT_FOUND, + PARSE_ERROR, + REQUEST_TIMEOUT, + URL_ELICITATION_REQUIRED, + ErrorData, + JSONRPCError, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, + RequestId, + jsonrpc_message_adapter, +) + +__all__ = [ + # Protocol version constants + "LATEST_PROTOCOL_VERSION", + "DEFAULT_NEGOTIATED_VERSION", + # Task execution mode constants + "TASK_FORBIDDEN", + "TASK_OPTIONAL", + "TASK_REQUIRED", + # Task status constants + "TASK_STATUS_CANCELLED", + "TASK_STATUS_COMPLETED", + "TASK_STATUS_FAILED", + "TASK_STATUS_INPUT_REQUIRED", + "TASK_STATUS_WORKING", + # Type aliases and variables + "ContentBlock", + "ElicitRequestedSchema", + "ElicitRequestParams", + "IncludeContext", + "LoggingLevel", + "ProgressToken", + "Role", + "SamplingContent", + "SamplingMessageContentBlock", + "StopReason", + "TaskExecutionMode", + "TaskStatus", + # Base classes + "BaseMetadata", + "Request", + "Notification", + "Result", + "RequestParams", + "RequestParamsMeta", + "NotificationParams", + "PaginatedRequest", + "PaginatedRequestParams", + "PaginatedResult", + "EmptyResult", + # Capabilities + "ClientCapabilities", + "ClientTasksCapability", + "ClientTasksRequestsCapability", + "CompletionsCapability", + "ElicitationCapability", + "FormElicitationCapability", + "LoggingCapability", + "PromptsCapability", + "ResourcesCapability", + "RootsCapability", + "SamplingCapability", + "SamplingContextCapability", + "SamplingToolsCapability", + "ServerCapabilities", + "ServerTasksCapability", + "ServerTasksRequestsCapability", + "TasksCancelCapability", + "TasksCallCapability", + "TasksCreateElicitationCapability", + "TasksCreateMessageCapability", + "TasksElicitationCapability", + "TasksListCapability", + "TasksSamplingCapability", + "TasksToolsCapability", + "ToolsCapability", + "UrlElicitationCapability", + # Content types + "Annotations", + "AudioContent", + "BlobResourceContents", + "EmbeddedResource", + "Icon", + "IconTheme", + "ImageContent", + "ResourceContents", + "ResourceLink", + "TextContent", + "TextResourceContents", + "ToolResultContent", + "ToolUseContent", + # Entity types + "Completion", + "CompletionArgument", + "CompletionContext", + "Implementation", + "ModelHint", + "ModelPreferences", + "Prompt", + "PromptArgument", + "PromptMessage", + "PromptReference", + "Resource", + "ResourceTemplate", + "ResourceTemplateReference", + "Root", + "SamplingMessage", + "Task", + "TaskMetadata", + "RelatedTaskMetadata", + "Tool", + "ToolAnnotations", + "ToolChoice", + "ToolExecution", + # Requests + "CallToolRequest", + "CallToolRequestParams", + "CancelTaskRequest", + "CancelTaskRequestParams", + "CompleteRequest", + "CompleteRequestParams", + "CreateMessageRequest", + "CreateMessageRequestParams", + "ElicitRequest", + "ElicitRequestFormParams", + "ElicitRequestURLParams", + "GetPromptRequest", + "GetPromptRequestParams", + "GetTaskPayloadRequest", + "GetTaskPayloadRequestParams", + "GetTaskRequest", + "GetTaskRequestParams", + "InitializeRequest", + "InitializeRequestParams", + "ListPromptsRequest", + "ListResourcesRequest", + "ListResourceTemplatesRequest", + "ListRootsRequest", + "ListTasksRequest", + "ListToolsRequest", + "PingRequest", + "ReadResourceRequest", + "ReadResourceRequestParams", + "SetLevelRequest", + "SetLevelRequestParams", + "SubscribeRequest", + "SubscribeRequestParams", + "UnsubscribeRequest", + "UnsubscribeRequestParams", + # Results + "CallToolResult", + "CancelTaskResult", + "CompleteResult", + "CreateMessageResult", + "CreateMessageResultWithTools", + "CreateTaskResult", + "ElicitResult", + "ElicitationRequiredErrorData", + "GetPromptResult", + "GetTaskPayloadResult", + "GetTaskResult", + "InitializeResult", + "ListPromptsResult", + "ListResourcesResult", + "ListResourceTemplatesResult", + "ListRootsResult", + "ListTasksResult", + "ListToolsResult", + "ReadResourceResult", + # Notifications + "CancelledNotification", + "CancelledNotificationParams", + "ElicitCompleteNotification", + "ElicitCompleteNotificationParams", + "InitializedNotification", + "LoggingMessageNotification", + "LoggingMessageNotificationParams", + "ProgressNotification", + "ProgressNotificationParams", + "PromptListChangedNotification", + "ResourceListChangedNotification", + "ResourceUpdatedNotification", + "ResourceUpdatedNotificationParams", + "RootsListChangedNotification", + "TaskStatusNotification", + "TaskStatusNotificationParams", + "ToolListChangedNotification", + # Union types for request/response routing + "ClientNotification", + "ClientRequest", + "ClientResult", + "ServerNotification", + "ServerRequest", + "ServerResult", + # Type adapters + "client_notification_adapter", + "client_request_adapter", + "client_result_adapter", + "server_notification_adapter", + "server_request_adapter", + "server_result_adapter", + # JSON-RPC types + "CONNECTION_CLOSED", + "INTERNAL_ERROR", + "INVALID_PARAMS", + "INVALID_REQUEST", + "METHOD_NOT_FOUND", + "PARSE_ERROR", + "REQUEST_TIMEOUT", + "URL_ELICITATION_REQUIRED", + "ErrorData", + "JSONRPCError", + "JSONRPCMessage", + "JSONRPCNotification", + "JSONRPCRequest", + "JSONRPCResponse", + "RequestId", + "jsonrpc_message_adapter", +] diff --git a/src/mcp/types.py b/src/mcp/types/_types.py similarity index 71% rename from src/mcp/types.py rename to src/mcp/types/_types.py index 9ca8ffc18..9005d253a 100644 --- a/src/mcp/types.py +++ b/src/mcp/types/_types.py @@ -1,44 +1,34 @@ -from collections.abc import Callable +from __future__ import annotations + from datetime import datetime from typing import Annotated, Any, Final, Generic, Literal, TypeAlias, TypeVar -from pydantic import BaseModel, ConfigDict, Field, FileUrl, RootModel -from pydantic.networks import AnyUrl, UrlConstraints -from typing_extensions import deprecated - -""" -Model Context Protocol bindings for Python +from pydantic import BaseModel, ConfigDict, Field, FileUrl, TypeAdapter +from pydantic.alias_generators import to_camel +from typing_extensions import NotRequired, TypedDict -These bindings were generated from https://github.com/modelcontextprotocol/specification, -using Claude, with a prompt something like the following: +from mcp.types.jsonrpc import RequestId -Generate idiomatic Python bindings for this schema for MCP, or the "Model Context -Protocol." The schema is defined in TypeScript, but there's also a JSON Schema version -for reference. +LATEST_PROTOCOL_VERSION = "2025-11-25" +"""The latest version of the Model Context Protocol. -* For the bindings, let's use Pydantic V2 models. -* Each model should allow extra fields everywhere, by specifying `model_config = - ConfigDict(extra='allow')`. Do this in every case, instead of a custom base class. -* Union types should be represented with a Pydantic `RootModel`. -* Define additional model classes instead of using dictionaries. Do this even if they're - not separate types in the schema. +You can find the latest specification at https://modelcontextprotocol.io/specification/latest. """ -LATEST_PROTOCOL_VERSION = "2025-11-25" +DEFAULT_NEGOTIATED_VERSION = "2025-03-26" +"""The default negotiated version of the Model Context Protocol when no version is specified. +We need this to satisfy the MCP specification, which requires the server to assume a specific version if none is +provided by the client. + +See the "Protocol Version Header" at +https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header. """ -The default negotiated version of the Model Context Protocol when no version is specified. -We need this to satisfy the MCP specification, which requires the server to assume a -specific version if none is provided by the client. See section "Protocol Version Header" at -https://modelcontextprotocol.io/specification -""" -DEFAULT_NEGOTIATED_VERSION = "2025-03-26" ProgressToken = str | int -Cursor = str Role = Literal["user", "assistant"] -RequestId = Annotated[int, Field(strict=True)] | str -AnyFunction: TypeAlias = Callable[..., Any] + +IconTheme = Literal["light", "dark"] TaskExecutionMode = Literal["forbidden", "optional", "required"] TASK_FORBIDDEN: Final[Literal["forbidden"]] = "forbidden" @@ -46,30 +36,36 @@ TASK_REQUIRED: Final[Literal["required"]] = "required" -class TaskMetadata(BaseModel): +class MCPModel(BaseModel): + """Base class for all MCP protocol types.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +Meta: TypeAlias = dict[str, Any] + + +class RequestParamsMeta(TypedDict, extra_items=Any): + progress_token: NotRequired[ProgressToken] """ - Metadata for augmenting a request with task execution. - Include this in the `task` field of the request parameters. + If specified, the caller requests 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. """ - model_config = ConfigDict(extra="allow") - ttl: Annotated[int, Field(strict=True)] | None = None - """Requested duration in milliseconds to retain task from creation.""" +class TaskMetadata(MCPModel): + """Metadata for augmenting a request with task execution. + Include this in the `task` field of the request parameters. + """ -class RequestParams(BaseModel): - class Meta(BaseModel): - progressToken: ProgressToken | None = None - """ - If specified, the caller requests 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. - """ + ttl: Annotated[int, Field(strict=True)] | None = None + """Requested duration in milliseconds to retain task from creation.""" - model_config = ConfigDict(extra="allow") +class RequestParams(MCPModel): task: TaskMetadata | None = None """ If specified, the caller is requesting task-augmented execution for this request. @@ -80,21 +76,18 @@ class Meta(BaseModel): for task augmentation of specific request types in their capabilities. """ - meta: Meta | None = Field(alias="_meta", default=None) + meta: RequestParamsMeta | None = Field(alias="_meta", default=None) class PaginatedRequestParams(RequestParams): - cursor: Cursor | None = None - """ - An opaque token representing the current pagination position. + cursor: str | None = None + """An opaque token representing the current pagination position. + If provided, the server should return results starting after this cursor. """ -class NotificationParams(BaseModel): - class Meta(BaseModel): - model_config = ConfigDict(extra="allow") - +class NotificationParams(MCPModel): meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) @@ -107,128 +100,49 @@ class Meta(BaseModel): MethodT = TypeVar("MethodT", bound=str) -class Request(BaseModel, Generic[RequestParamsT, MethodT]): +class Request(MCPModel, Generic[RequestParamsT, MethodT]): """Base class for JSON-RPC requests.""" method: MethodT params: RequestParamsT - model_config = ConfigDict(extra="allow") class PaginatedRequest(Request[PaginatedRequestParams | None, MethodT], Generic[MethodT]): - """Base class for paginated requests, - matching the schema's PaginatedRequest interface.""" + """Base class for paginated requests, matching the schema's PaginatedRequest interface.""" params: PaginatedRequestParams | None = None -class Notification(BaseModel, Generic[NotificationParamsT, MethodT]): +class Notification(MCPModel, Generic[NotificationParamsT, MethodT]): """Base class for JSON-RPC notifications.""" method: MethodT params: NotificationParamsT - model_config = ConfigDict(extra="allow") -class Result(BaseModel): +class Result(MCPModel): """Base class for JSON-RPC results.""" - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. """ - model_config = ConfigDict(extra="allow") class PaginatedResult(Result): - nextCursor: Cursor | None = None + next_cursor: str | None = None """ An opaque token representing the pagination position after the last returned result. If present, there may be more results available. """ -class JSONRPCRequest(Request[dict[str, Any] | None, str]): - """A request that expects a response.""" - - jsonrpc: Literal["2.0"] - id: RequestId - method: str - params: dict[str, Any] | None = None - - -class JSONRPCNotification(Notification[dict[str, Any] | None, str]): - """A notification which does not expect a response.""" - - jsonrpc: Literal["2.0"] - params: dict[str, Any] | None = None - - -class JSONRPCResponse(BaseModel): - """A successful (non-error) response to a request.""" - - jsonrpc: Literal["2.0"] - id: RequestId - result: dict[str, Any] - model_config = ConfigDict(extra="allow") - - -# MCP-specific error codes in the range [-32000, -32099] -URL_ELICITATION_REQUIRED = -32042 -"""Error code indicating that a URL mode elicitation is required before the request can be processed.""" - -# SDK error codes -CONNECTION_CLOSED = -32000 -# REQUEST_TIMEOUT = -32001 # the typescript sdk uses this - -# Standard JSON-RPC error codes -PARSE_ERROR = -32700 -INVALID_REQUEST = -32600 -METHOD_NOT_FOUND = -32601 -INVALID_PARAMS = -32602 -INTERNAL_ERROR = -32603 - - -class ErrorData(BaseModel): - """Error information for JSON-RPC error responses.""" - - code: int - """The error type that occurred.""" - - message: str - """ - A short description of the error. The message SHOULD be limited to a concise single - sentence. - """ - - data: Any | None = None - """ - Additional information about the error. The value of this member is defined by the - sender (e.g. detailed error information, nested errors etc.). - """ - - model_config = ConfigDict(extra="allow") - - -class JSONRPCError(BaseModel): - """A response to a request that indicates an error occurred.""" - - jsonrpc: Literal["2.0"] - id: str | int - error: ErrorData - model_config = ConfigDict(extra="allow") - - -class JSONRPCMessage(RootModel[JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError]): - pass - - class EmptyResult(Result): """A response that indicates success but carries no data.""" -class BaseMetadata(BaseModel): +class BaseMetadata(MCPModel): """Base class for entities with name and optional title fields.""" name: str @@ -245,19 +159,26 @@ class BaseMetadata(BaseModel): """ -class Icon(BaseModel): +class Icon(MCPModel): """An icon for display in user interfaces.""" src: str """URL or data URI for the icon.""" - mimeType: str | None = None + mime_type: str | None = None """Optional MIME type for the icon.""" sizes: list[str] | None = None """Optional list of strings specifying icon dimensions (e.g., ["48x48", "96x96"]).""" - model_config = ConfigDict(extra="allow") + theme: IconTheme | None = None + """Optional theme specifier. + + `"light"` indicates the icon is designed for a light background, `"dark"` indicates the icon + is designed for a dark background. + + See https://modelcontextprotocol.io/specification/2025-11-25/schema#icon for more details. + """ class Implementation(BaseMetadata): @@ -265,58 +186,51 @@ class Implementation(BaseMetadata): version: str - websiteUrl: str | None = None + title: str | None = None + """An optional human-readable title for this implementation.""" + + description: str | None = None + """An optional human-readable description of what this implementation does.""" + + website_url: str | None = None """An optional URL of the website for this implementation.""" icons: list[Icon] | None = None """An optional list of icons for this implementation.""" - model_config = ConfigDict(extra="allow") - -class RootsCapability(BaseModel): +class RootsCapability(MCPModel): """Capability for root operations.""" - listChanged: bool | None = None + list_changed: bool | None = None """Whether the client supports notifications for changes to the roots list.""" - model_config = ConfigDict(extra="allow") -class SamplingContextCapability(BaseModel): - """ - Capability for context inclusion during sampling. +class SamplingContextCapability(MCPModel): + """Capability for context inclusion during sampling. Indicates support for non-'none' values in the includeContext parameter. SOFT-DEPRECATED: New implementations should use tools parameter instead. """ - model_config = ConfigDict(extra="allow") - -class SamplingToolsCapability(BaseModel): - """ - Capability indicating support for tool calling during sampling. +class SamplingToolsCapability(MCPModel): + """Capability indicating support for tool calling during sampling. When present in ClientCapabilities.sampling, indicates that the client supports the tools and toolChoice parameters in sampling requests. """ - model_config = ConfigDict(extra="allow") - -class FormElicitationCapability(BaseModel): +class FormElicitationCapability(MCPModel): """Capability for form mode elicitation.""" - model_config = ConfigDict(extra="allow") - -class UrlElicitationCapability(BaseModel): +class UrlElicitationCapability(MCPModel): """Capability for URL mode elicitation.""" - model_config = ConfigDict(extra="allow") - -class ElicitationCapability(BaseModel): +class ElicitationCapability(MCPModel): """Capability for elicitation operations. Clients must support at least one mode (form or url). @@ -328,13 +242,9 @@ class ElicitationCapability(BaseModel): url: UrlElicitationCapability | None = None """Present if the client supports URL mode elicitation.""" - model_config = ConfigDict(extra="allow") - -class SamplingCapability(BaseModel): - """ - Sampling capability structure, allowing fine-grained capability advertisement. - """ +class SamplingCapability(MCPModel): + """Sampling capability structure, allowing fine-grained capability advertisement.""" context: SamplingContextCapability | None = None """ @@ -346,64 +256,47 @@ class SamplingCapability(BaseModel): Present if the client supports tools and toolChoice parameters in sampling requests. Presence indicates full tool calling support during sampling. """ - model_config = ConfigDict(extra="allow") -class TasksListCapability(BaseModel): +class TasksListCapability(MCPModel): """Capability for tasks listing operations.""" - model_config = ConfigDict(extra="allow") - -class TasksCancelCapability(BaseModel): +class TasksCancelCapability(MCPModel): """Capability for tasks cancel operations.""" - model_config = ConfigDict(extra="allow") - -class TasksCreateMessageCapability(BaseModel): +class TasksCreateMessageCapability(MCPModel): """Capability for tasks create messages.""" - model_config = ConfigDict(extra="allow") - -class TasksSamplingCapability(BaseModel): +class TasksSamplingCapability(MCPModel): """Capability for tasks sampling operations.""" - model_config = ConfigDict(extra="allow") - - createMessage: TasksCreateMessageCapability | None = None + create_message: TasksCreateMessageCapability | None = None -class TasksCreateElicitationCapability(BaseModel): +class TasksCreateElicitationCapability(MCPModel): """Capability for tasks create elicitation operations.""" - model_config = ConfigDict(extra="allow") - -class TasksElicitationCapability(BaseModel): +class TasksElicitationCapability(MCPModel): """Capability for tasks elicitation operations.""" - model_config = ConfigDict(extra="allow") - create: TasksCreateElicitationCapability | None = None -class ClientTasksRequestsCapability(BaseModel): +class ClientTasksRequestsCapability(MCPModel): """Capability for tasks requests operations.""" - model_config = ConfigDict(extra="allow") - sampling: TasksSamplingCapability | None = None elicitation: TasksElicitationCapability | None = None -class ClientTasksCapability(BaseModel): +class ClientTasksCapability(MCPModel): """Capability for client tasks operations.""" - model_config = ConfigDict(extra="allow") - list: TasksListCapability | None = None """Whether this client supports tasks/list.""" @@ -414,7 +307,7 @@ class ClientTasksCapability(BaseModel): """Specifies which request types can be augmented with tasks.""" -class ClientCapabilities(BaseModel): +class ClientCapabilities(MCPModel): """Capabilities a client may support.""" experimental: dict[str, dict[str, Any]] | None = None @@ -431,96 +324,85 @@ class ClientCapabilities(BaseModel): tasks: ClientTasksCapability | None = None """Present if the client supports task-augmented requests.""" - model_config = ConfigDict(extra="allow") - -class PromptsCapability(BaseModel): +class PromptsCapability(MCPModel): """Capability for prompts operations.""" - listChanged: bool | None = None + list_changed: bool | None = None """Whether this server supports notifications for changes to the prompt list.""" - model_config = ConfigDict(extra="allow") -class ResourcesCapability(BaseModel): +class ResourcesCapability(MCPModel): """Capability for resources operations.""" subscribe: bool | None = None """Whether this server supports subscribing to resource updates.""" - listChanged: bool | None = None + list_changed: bool | None = None """Whether this server supports notifications for changes to the resource list.""" - model_config = ConfigDict(extra="allow") -class ToolsCapability(BaseModel): +class ToolsCapability(MCPModel): """Capability for tools operations.""" - listChanged: bool | None = None + list_changed: bool | None = None """Whether this server supports notifications for changes to the tool list.""" - model_config = ConfigDict(extra="allow") -class LoggingCapability(BaseModel): +class LoggingCapability(MCPModel): """Capability for logging operations.""" - model_config = ConfigDict(extra="allow") - -class CompletionsCapability(BaseModel): +class CompletionsCapability(MCPModel): """Capability for completions operations.""" - model_config = ConfigDict(extra="allow") - -class TasksCallCapability(BaseModel): +class TasksCallCapability(MCPModel): """Capability for tasks call operations.""" - model_config = ConfigDict(extra="allow") - -class TasksToolsCapability(BaseModel): +class TasksToolsCapability(MCPModel): """Capability for tasks tools operations.""" - model_config = ConfigDict(extra="allow") call: TasksCallCapability | None = None -class ServerTasksRequestsCapability(BaseModel): +class ServerTasksRequestsCapability(MCPModel): """Capability for tasks requests operations.""" - model_config = ConfigDict(extra="allow") - tools: TasksToolsCapability | None = None -class ServerTasksCapability(BaseModel): +class ServerTasksCapability(MCPModel): """Capability for server tasks operations.""" - model_config = ConfigDict(extra="allow") - list: TasksListCapability | None = None cancel: TasksCancelCapability | None = None requests: ServerTasksRequestsCapability | None = None -class ServerCapabilities(BaseModel): +class ServerCapabilities(MCPModel): """Capabilities that a server may support.""" experimental: dict[str, dict[str, Any]] | None = None """Experimental, non-standard capabilities that the server supports.""" + logging: LoggingCapability | None = None """Present if the server supports sending log messages to the client.""" + prompts: PromptsCapability | None = None """Present if the server offers any prompt templates.""" + resources: ResourcesCapability | None = None """Present if the server offers any resources to read.""" + tools: ToolsCapability | None = None """Present if the server offers any tools to call.""" + completions: CompletionsCapability | None = None """Present if the server offers autocompletion suggestions for prompts and resources.""" + tasks: ServerTasksCapability | None = None """Present if the server supports task-augmented requests.""" - model_config = ConfigDict(extra="allow") TaskStatus = Literal["working", "input_required", "completed", "failed", "cancelled"] @@ -533,48 +415,44 @@ class ServerCapabilities(BaseModel): TASK_STATUS_CANCELLED: Final[Literal["cancelled"]] = "cancelled" -class RelatedTaskMetadata(BaseModel): - """ - Metadata for associating messages with a task. +class RelatedTaskMetadata(MCPModel): + """Metadata for associating messages with a task. Include this in the `_meta` field under the key `io.modelcontextprotocol/related-task`. """ - model_config = ConfigDict(extra="allow") - taskId: str + task_id: str """The task identifier this message is associated with.""" -class Task(BaseModel): +class Task(MCPModel): """Data associated with a task.""" - model_config = ConfigDict(extra="allow") - - taskId: str + task_id: str """The task identifier.""" status: TaskStatus """Current task state.""" - statusMessage: str | None = None - """ - Optional human-readable message describing the current task state. + status_message: str | None = None + """Optional human-readable message describing the current task state. + This can provide context for any status, including: - Reasons for "cancelled" status - Summaries for "completed" status - Diagnostic information for "failed" status (e.g., error details, what went wrong) """ - createdAt: datetime # Pydantic will enforce ISO 8601 and re-serialize as a string later + created_at: datetime # Pydantic will enforce ISO 8601 and re-serialize as a string later """ISO 8601 timestamp when the task was created.""" - lastUpdatedAt: datetime + last_updated_at: datetime """ISO 8601 timestamp when the task was last updated.""" ttl: Annotated[int, Field(strict=True)] | None """Actual retention duration from creation in milliseconds, null for unlimited.""" - pollInterval: Annotated[int, Field(strict=True)] | None = None + poll_interval: Annotated[int, Field(strict=True)] | None = None """Suggested polling interval in milliseconds.""" @@ -585,8 +463,7 @@ class CreateTaskResult(Result): class GetTaskRequestParams(RequestParams): - model_config = ConfigDict(extra="allow") - taskId: str + task_id: str """The task identifier to query.""" @@ -603,9 +480,7 @@ class GetTaskResult(Result, Task): class GetTaskPayloadRequestParams(RequestParams): - model_config = ConfigDict(extra="allow") - - taskId: str + task_id: str """The task identifier to retrieve results for.""" @@ -617,17 +492,17 @@ class GetTaskPayloadRequest(Request[GetTaskPayloadRequestParams, Literal["tasks/ class GetTaskPayloadResult(Result): - """ - The response to a tasks/result request. + """The response to a tasks/result request. + The structure matches the result type of the original request. For example, a tools/call task would return the CallToolResult structure. """ + model_config = ConfigDict(extra="allow", alias_generator=to_camel, populate_by_name=True) -class CancelTaskRequestParams(RequestParams): - model_config = ConfigDict(extra="allow") - taskId: str +class CancelTaskRequestParams(RequestParams): + task_id: str """The task identifier to cancel.""" @@ -659,9 +534,8 @@ class TaskStatusNotificationParams(NotificationParams, Task): class TaskStatusNotification(Notification[TaskStatusNotificationParams, Literal["notifications/tasks/status"]]): - """ - 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 + """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. """ method: Literal["notifications/tasks/status"] = "notifications/tasks/status" @@ -671,16 +545,14 @@ class TaskStatusNotification(Notification[TaskStatusNotificationParams, Literal[ class InitializeRequestParams(RequestParams): """Parameters for the initialize request.""" - protocolVersion: str | int + protocol_version: str | int """The latest version of the Model Context Protocol that the client supports.""" capabilities: ClientCapabilities - clientInfo: Implementation - model_config = ConfigDict(extra="allow") + client_info: Implementation class InitializeRequest(Request[InitializeRequestParams, Literal["initialize"]]): - """ - This request is sent from the client to the server when it first connects, asking it + """This request is sent from the client to the server when it first connects, asking it to begin initialization. """ @@ -691,17 +563,16 @@ class InitializeRequest(Request[InitializeRequestParams, Literal["initialize"]]) class InitializeResult(Result): """After receiving an initialize request from the client, the server sends this.""" - protocolVersion: str | int + protocol_version: str | int """The version of the Model Context Protocol that the server wants to use.""" capabilities: ServerCapabilities - serverInfo: Implementation + server_info: Implementation instructions: str | None = None """Instructions describing how to use the server and its features.""" class InitializedNotification(Notification[NotificationParams | None, Literal["notifications/initialized"]]): - """ - This notification is sent from the client to the server after initialization has + """This notification is sent from the client to the server after initialization has finished. """ @@ -710,8 +581,7 @@ class InitializedNotification(Notification[NotificationParams | None, Literal["n class PingRequest(Request[RequestParams | None, Literal["ping"]]): - """ - A ping, issued by either the server or the client, to check that the other party is + """A ping, issued by either the server or the client, to check that the other party is still alive. """ @@ -722,7 +592,7 @@ class PingRequest(Request[RequestParams | None, Literal["ping"]]): class ProgressNotificationParams(NotificationParams): """Parameters for progress notifications.""" - progressToken: ProgressToken + progress_token: ProgressToken """ The progress token which was given in the initial request, used to associate this notification with the request that is proceeding. @@ -735,18 +605,14 @@ class ProgressNotificationParams(NotificationParams): total: float | None = None """Total number of items to process (or total progress required), if known.""" message: str | None = None + """Message related to progress. + + This should provide relevant human-readable progress information. """ - Message related to progress. This should provide relevant human readable - progress information. - """ - model_config = ConfigDict(extra="allow") class ProgressNotification(Notification[ProgressNotificationParams, Literal["notifications/progress"]]): - """ - An out-of-band notification used to inform the receiver of a progress update for a - long-running request. - """ + """An out-of-band notification used to inform the receiver of a progress update for a long-running request.""" method: Literal["notifications/progress"] = "notifications/progress" params: ProgressNotificationParams @@ -758,63 +624,66 @@ class ListResourcesRequest(PaginatedRequest[Literal["resources/list"]]): method: Literal["resources/list"] = "resources/list" -class Annotations(BaseModel): +class Annotations(MCPModel): audience: list[Role] | None = None priority: Annotated[float, Field(ge=0.0, le=1.0)] | None = None - model_config = ConfigDict(extra="allow") class Resource(BaseMetadata): """A known resource that the server is capable of reading.""" - uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] + uri: str """The URI of this resource.""" + description: str | None = None """A description of what this resource represents.""" - mimeType: str | None = None + + mime_type: str | None = None """The MIME type of this resource, if known.""" + size: int | None = None - """ - The size of the raw resource content, in bytes (i.e., before base64 encoding - or any tokenization), if known. + """The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. This can be used by Hosts to display file sizes and estimate context window usage. """ + icons: list[Icon] | None = None """An optional list of icons for this resource.""" + annotations: Annotations | None = None - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. """ - model_config = ConfigDict(extra="allow") class ResourceTemplate(BaseMetadata): """A template description for resources available on the server.""" - uriTemplate: str - """ - A URI template (according to RFC 6570) that can be used to construct resource - URIs. - """ + uri_template: str + """A URI template (according to RFC 6570) that can be used to construct resource URIs.""" + description: str | None = None """A human-readable description of what this template is for.""" - mimeType: str | None = None - """ - 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. + + mime_type: str | None = None + """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. """ + icons: list[Icon] | None = None """An optional list of icons for this resource template.""" + annotations: Annotations | None = None - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. """ - model_config = ConfigDict(extra="allow") class ListResourcesResult(PaginatedResult): @@ -832,18 +701,17 @@ class ListResourceTemplatesRequest(PaginatedRequest[Literal["resources/templates class ListResourceTemplatesResult(PaginatedResult): """The server's response to a resources/templates/list request from the client.""" - resourceTemplates: list[ResourceTemplate] + resource_templates: list[ResourceTemplate] class ReadResourceRequestParams(RequestParams): """Parameters for reading a resource.""" - uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] + uri: str """ The URI of the resource to read. The URI can use any protocol; it is up to the server how to interpret it. """ - model_config = ConfigDict(extra="allow") class ReadResourceRequest(Request[ReadResourceRequestParams, Literal["resources/read"]]): @@ -853,19 +721,18 @@ class ReadResourceRequest(Request[ReadResourceRequestParams, Literal["resources/ params: ReadResourceRequestParams -class ResourceContents(BaseModel): +class ResourceContents(MCPModel): """The contents of a specific resource or sub-resource.""" - uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] + uri: str """The URI of this resource.""" - mimeType: str | None = None + mime_type: str | None = None """The MIME type of this resource, if known.""" - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. """ - model_config = ConfigDict(extra="allow") class TextResourceContents(ResourceContents): @@ -894,8 +761,7 @@ class ReadResourceResult(Result): class ResourceListChangedNotification( Notification[NotificationParams | None, Literal["notifications/resources/list_changed"]] ): - """ - An optional notification from the server to the client, informing it that the list + """An optional notification from the server to the client, informing it that the list of resources it can read from has changed. """ @@ -906,17 +772,15 @@ class ResourceListChangedNotification( class SubscribeRequestParams(RequestParams): """Parameters for subscribing to a resource.""" - uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] + uri: str """ The URI of the resource to subscribe to. The URI can use any protocol; it is up to the server how to interpret it. """ - model_config = ConfigDict(extra="allow") class SubscribeRequest(Request[SubscribeRequestParams, Literal["resources/subscribe"]]): - """ - Sent from the client to request resources/updated notifications from the server + """Sent from the client to request resources/updated notifications from the server whenever a particular resource changes. """ @@ -927,14 +791,12 @@ class SubscribeRequest(Request[SubscribeRequestParams, Literal["resources/subscr class UnsubscribeRequestParams(RequestParams): """Parameters for unsubscribing from a resource.""" - uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] + uri: str """The URI of the resource to unsubscribe from.""" - model_config = ConfigDict(extra="allow") class UnsubscribeRequest(Request[UnsubscribeRequestParams, Literal["resources/unsubscribe"]]): - """ - Sent from the client to request cancellation of resources/updated notifications from + """Sent from the client to request cancellation of resources/updated notifications from the server. """ @@ -945,19 +807,17 @@ class UnsubscribeRequest(Request[UnsubscribeRequestParams, Literal["resources/un class ResourceUpdatedNotificationParams(NotificationParams): """Parameters for resource update notifications.""" - uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] + uri: str """ The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to. """ - model_config = ConfigDict(extra="allow") class ResourceUpdatedNotification( Notification[ResourceUpdatedNotificationParams, Literal["notifications/resources/updated"]] ): - """ - A notification from the server to the client, informing it that a resource has + """A notification from the server to the client, informing it that a resource has changed and may need to be read again. """ @@ -971,7 +831,7 @@ class ListPromptsRequest(PaginatedRequest[Literal["prompts/list"]]): method: Literal["prompts/list"] = "prompts/list" -class PromptArgument(BaseModel): +class PromptArgument(MCPModel): """An argument for a prompt template.""" name: str @@ -980,7 +840,6 @@ class PromptArgument(BaseModel): """A human-readable description of the argument.""" required: bool | None = None """Whether this argument must be provided.""" - model_config = ConfigDict(extra="allow") class Prompt(BaseMetadata): @@ -992,12 +851,11 @@ class Prompt(BaseMetadata): """A list of arguments to use for templating the prompt.""" icons: list[Icon] | None = None """An optional list of icons for this prompt.""" - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. """ - model_config = ConfigDict(extra="allow") class ListPromptsResult(PaginatedResult): @@ -1013,7 +871,6 @@ class GetPromptRequestParams(RequestParams): """The name of the prompt or prompt template.""" arguments: dict[str, str] | None = None """Arguments to use for templating the prompt.""" - model_config = ConfigDict(extra="allow") class GetPromptRequest(Request[GetPromptRequestParams, Literal["prompts/get"]]): @@ -1023,71 +880,67 @@ class GetPromptRequest(Request[GetPromptRequestParams, Literal["prompts/get"]]): params: GetPromptRequestParams -class TextContent(BaseModel): +class TextContent(MCPModel): """Text content for a message.""" - type: Literal["text"] + type: Literal["text"] = "text" text: str """The text content of the message.""" annotations: Annotations | None = None - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. """ - model_config = ConfigDict(extra="allow") -class ImageContent(BaseModel): +class ImageContent(MCPModel): """Image content for a message.""" - type: Literal["image"] + type: Literal["image"] = "image" data: str """The base64-encoded image data.""" - mimeType: str + mime_type: str """ The MIME type of the image. Different providers may support different image types. """ annotations: Annotations | None = None - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. """ - model_config = ConfigDict(extra="allow") -class AudioContent(BaseModel): +class AudioContent(MCPModel): """Audio content for a message.""" - type: Literal["audio"] + type: Literal["audio"] = "audio" data: str """The base64-encoded audio data.""" - mimeType: str + mime_type: str """ The MIME type of the audio. Different providers may support different audio types. """ annotations: Annotations | None = None - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. """ - model_config = ConfigDict(extra="allow") -class ToolUseContent(BaseModel): - """ - Content representing an assistant's request to invoke a tool. +class ToolUseContent(MCPModel): + """Content representing an assistant's request to invoke a tool. This content type appears in assistant messages when the LLM wants to call a tool during sampling. The server should execute the tool and return a ToolResultContent in the next user message. """ - type: Literal["tool_use"] + type: Literal["tool_use"] = "tool_use" """Discriminator for tool use content.""" name: str @@ -1099,48 +952,45 @@ class ToolUseContent(BaseModel): input: dict[str, Any] """Arguments to pass to the tool. Must conform to the tool's inputSchema.""" - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. """ - model_config = ConfigDict(extra="allow") -class ToolResultContent(BaseModel): - """ - Content representing the result of a tool execution. +class ToolResultContent(MCPModel): + """Content representing the result of a tool execution. This content type appears in user messages as a response to a ToolUseContent from the assistant. It contains the output of executing the requested tool. """ - type: Literal["tool_result"] + type: Literal["tool_result"] = "tool_result" """Discriminator for tool result content.""" - toolUseId: str + tool_use_id: str """The unique identifier that corresponds to the tool call's id field.""" - content: list["ContentBlock"] = [] + content: list[ContentBlock] = [] """ A list of content objects representing the tool result. Defaults to empty list if not provided. """ - structuredContent: dict[str, Any] | None = None + structured_content: dict[str, Any] | None = None """ Optional structured tool output that matches the tool's outputSchema (if defined). """ - isError: bool | None = None + is_error: bool | None = None """Whether the tool execution resulted in an error.""" - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. """ - model_config = ConfigDict(extra="allow") SamplingMessageContentBlock: TypeAlias = TextContent | ImageContent | AudioContent | ToolUseContent | ToolResultContent @@ -1148,10 +998,12 @@ class ToolResultContent(BaseModel): SamplingContent: TypeAlias = TextContent | ImageContent | AudioContent """Basic content types for sampling responses (without tool use). -Used for backwards-compatible CreateMessageResult when tools are not used.""" + +Used for backwards-compatible CreateMessageResult when tools are not used. +""" -class SamplingMessage(BaseModel): +class SamplingMessage(MCPModel): """Describes a message issued to or received from an LLM API.""" role: Role @@ -1160,12 +1012,11 @@ class SamplingMessage(BaseModel): Message content. Can be a single content block or an array of content blocks for multi-modal messages and tool interactions. """ - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. """ - model_config = ConfigDict(extra="allow") @property def content_as_list(self) -> list[SamplingMessageContentBlock]: @@ -1174,48 +1025,41 @@ def content_as_list(self) -> list[SamplingMessageContentBlock]: return self.content if isinstance(self.content, list) else [self.content] -class EmbeddedResource(BaseModel): - """ - The contents of a resource, embedded into a prompt or tool call result. +class EmbeddedResource(MCPModel): + """The contents of a resource, embedded into a prompt or tool call result. It is up to the client how best to render embedded resources for the benefit of the LLM and/or the user. """ - type: Literal["resource"] + type: Literal["resource"] = "resource" resource: TextResourceContents | BlobResourceContents annotations: Annotations | None = None - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. """ - model_config = ConfigDict(extra="allow") class ResourceLink(Resource): - """ - A resource that the server is capable of reading, included in a prompt or tool call result. + """A resource that the server is capable of reading, included in a prompt or tool call result. Note: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests. """ - type: Literal["resource_link"] + type: Literal["resource_link"] = "resource_link" ContentBlock = TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource """A content block that can be used in prompts and tool results.""" -Content: TypeAlias = ContentBlock -# """DEPRECATED: Content is deprecated, you should use ContentBlock directly.""" - -class PromptMessage(BaseModel): +class PromptMessage(MCPModel): """Describes a message returned as part of a prompt.""" role: Role content: ContentBlock - model_config = ConfigDict(extra="allow") class GetPromptResult(Result): @@ -1229,8 +1073,7 @@ class GetPromptResult(Result): class PromptListChangedNotification( Notification[NotificationParams | None, Literal["notifications/prompts/list_changed"]] ): - """ - An optional notification from the server to the client, informing it that the list + """An optional notification from the server to the client, informing it that the list of prompts it offers has changed. """ @@ -1244,9 +1087,8 @@ class ListToolsRequest(PaginatedRequest[Literal["tools/list"]]): method: Literal["tools/list"] = "tools/list" -class ToolAnnotations(BaseModel): - """ - Additional properties describing a Tool to clients. +class ToolAnnotations(MCPModel): + """Additional properties describing a Tool to clients. NOTE: all properties in ToolAnnotations are **hints**. They are not guaranteed to provide a faithful description of @@ -1259,29 +1101,29 @@ class ToolAnnotations(BaseModel): title: str | None = None """A human-readable title for the tool.""" - readOnlyHint: bool | None = None + read_only_hint: bool | None = None """ If true, the tool does not modify its environment. Default: false """ - destructiveHint: bool | None = None + destructive_hint: bool | None = None """ If true, the tool may perform destructive updates to its environment. If false, the tool performs only additive updates. - (This property is meaningful only when `readOnlyHint == false`) + (This property is meaningful only when `read_only_hint == false`) Default: true """ - idempotentHint: bool | None = None + idempotent_hint: bool | None = None """ If true, calling the tool repeatedly with the same arguments - will have no additional effect on the its environment. - (This property is meaningful only when `readOnlyHint == false`) + will have no additional effect on its environment. + (This property is meaningful only when `read_only_hint == false`) Default: false """ - openWorldHint: bool | None = None + open_world_hint: bool | None = None """ If true, this tool may interact with an "open world" of external entities. If false, the tool's domain of interaction is closed. @@ -1290,15 +1132,11 @@ class ToolAnnotations(BaseModel): Default: true """ - model_config = ConfigDict(extra="allow") - -class ToolExecution(BaseModel): +class ToolExecution(MCPModel): """Execution-related properties for a tool.""" - model_config = ConfigDict(extra="allow") - - taskSupport: TaskExecutionMode | None = None + task_support: TaskExecutionMode | None = None """ Indicates whether this tool supports task-augmented execution. This allows clients to handle long-running operations through polling @@ -1317,18 +1155,18 @@ class Tool(BaseMetadata): description: str | None = None """A human-readable description of the tool.""" - inputSchema: dict[str, Any] + input_schema: dict[str, Any] """A JSON Schema object defining the expected parameters for the tool.""" - outputSchema: dict[str, Any] | None = None + output_schema: dict[str, Any] | None = None """ An optional JSON Schema object defining the structure of the tool's output - returned in the structuredContent field of a CallToolResult. + returned in the structured_content field of a CallToolResult. """ icons: list[Icon] | None = None """An optional list of icons for this tool.""" annotations: ToolAnnotations | None = None """Optional additional tool information.""" - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. @@ -1336,8 +1174,6 @@ class Tool(BaseMetadata): execution: ToolExecution | None = None - model_config = ConfigDict(extra="allow") - class ListToolsResult(PaginatedResult): """The server's response to a tools/list request from the client.""" @@ -1350,7 +1186,6 @@ class CallToolRequestParams(RequestParams): name: str arguments: dict[str, Any] | None = None - model_config = ConfigDict(extra="allow") class CallToolRequest(Request[CallToolRequestParams, Literal["tools/call"]]): @@ -1364,14 +1199,13 @@ class CallToolResult(Result): """The server's response to a tool call.""" content: list[ContentBlock] - structuredContent: dict[str, Any] | None = None + structured_content: dict[str, Any] | None = None """An optional JSON object that represents the structured result of the tool call.""" - isError: bool = False + is_error: bool = False class ToolListChangedNotification(Notification[NotificationParams | None, Literal["notifications/tools/list_changed"]]): - """ - An optional notification from the server to the client, informing it that the list + """An optional notification from the server to the client, informing it that the list of tools it offers has changed. """ @@ -1387,7 +1221,6 @@ class SetLevelRequestParams(RequestParams): level: LoggingLevel """The level of logging that the client wants to receive from the server.""" - model_config = ConfigDict(extra="allow") class SetLevelRequest(Request[SetLevelRequestParams, Literal["logging/setLevel"]]): @@ -1409,7 +1242,6 @@ class LoggingMessageNotificationParams(NotificationParams): The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here. """ - model_config = ConfigDict(extra="allow") class LoggingMessageNotification(Notification[LoggingMessageNotificationParams, Literal["notifications/message"]]): @@ -1422,22 +1254,19 @@ class LoggingMessageNotification(Notification[LoggingMessageNotificationParams, IncludeContext = Literal["none", "thisServer", "allServers"] -class ModelHint(BaseModel): +class ModelHint(MCPModel): """Hints to use for model selection.""" name: str | None = None """A hint for a model name.""" - model_config = ConfigDict(extra="allow") - -class ModelPreferences(BaseModel): - """ - The server's preferences for model selection, requested by the client during +class ModelPreferences(MCPModel): + """The server's preferences for model selection, requested by the client during sampling. Because LLMs can vary along multiple dimensions, choosing the "best" model is - rarely straightforward. Different models excel in different areas—some are + rarely straightforward. Different models excel in different areas—some are faster but less capable, others are more capable but more expensive, and so on. This interface allows servers to express their priorities across multiple dimensions to help clients make an appropriate selection for their use case. @@ -1458,33 +1287,30 @@ class ModelPreferences(BaseModel): MAY still use the priorities to select from ambiguous matches. """ - costPriority: float | None = None + cost_priority: float | None = None """ How much to prioritize cost when selecting a model. A value of 0 means cost is not important, while a value of 1 means cost is the most important factor. """ - speedPriority: float | None = None + speed_priority: float | None = None """ How much to prioritize sampling speed (latency) when selecting a model. A value of 0 means speed is not important, while a value of 1 means speed is the most important factor. """ - intelligencePriority: float | None = None + intelligence_priority: float | None = None """ How much to prioritize intelligence and capabilities when selecting a model. A value of 0 means intelligence is not important, while a value of 1 means intelligence is the most important factor. """ - model_config = ConfigDict(extra="allow") - -class ToolChoice(BaseModel): - """ - Controls tool usage behavior during sampling. +class ToolChoice(MCPModel): + """Controls tool usage behavior during sampling. Allows the server to specify whether and how the LLM should use tools in its response. @@ -1498,42 +1324,39 @@ class ToolChoice(BaseModel): - "none": Model should not use tools """ - model_config = ConfigDict(extra="allow") - class CreateMessageRequestParams(RequestParams): """Parameters for creating a message.""" messages: list[SamplingMessage] - modelPreferences: ModelPreferences | None = None + model_preferences: ModelPreferences | None = None """ The server's preferences for which model to select. The client MAY ignore these preferences. """ - systemPrompt: str | None = None + system_prompt: str | None = None """An optional system prompt the server wants to use for sampling.""" - includeContext: IncludeContext | None = None + include_context: IncludeContext | None = None """ A request to include context from one or more MCP servers (including the caller), to be attached to the prompt. """ temperature: float | None = None - maxTokens: int + max_tokens: int """The maximum number of tokens to sample, as requested by the server.""" - stopSequences: list[str] | None = None + stop_sequences: list[str] | None = None metadata: dict[str, Any] | None = None """Optional metadata to pass through to the LLM provider.""" - tools: list["Tool"] | None = None + tools: list[Tool] | None = None """ Tool definitions for the LLM to use during sampling. Requires clientCapabilities.sampling.tools to be present. """ - toolChoice: ToolChoice | None = None + tool_choice: ToolChoice | None = None """ Controls tool usage behavior. Requires clientCapabilities.sampling.tools and the tools parameter to be present. """ - model_config = ConfigDict(extra="allow") class CreateMessageRequest(Request[CreateMessageRequestParams, Literal["sampling/createMessage"]]): @@ -1547,7 +1370,7 @@ class CreateMessageRequest(Request[CreateMessageRequestParams, Literal["sampling class CreateMessageResult(Result): - """The client's response to a sampling/create_message request from the server. + """The client's response to a sampling/createMessage request from the server. This is the backwards-compatible version that returns single content (no arrays). Used when the request does not include tools. @@ -1559,12 +1382,12 @@ class CreateMessageResult(Result): """Response content. Single content block (text, image, or audio).""" model: str """The name of the model that generated the message.""" - stopReason: StopReason | None = None + stop_reason: StopReason | None = None """The reason why sampling stopped, if known.""" class CreateMessageResultWithTools(Result): - """The client's response to a sampling/create_message request when tools were provided. + """The client's response to a sampling/createMessage request when tools were provided. This version supports array content for tool use flows. """ @@ -1574,11 +1397,11 @@ class CreateMessageResultWithTools(Result): content: SamplingMessageContentBlock | list[SamplingMessageContentBlock] """ Response content. May be a single content block or an array. - May include ToolUseContent if stopReason is 'toolUse'. + May include ToolUseContent if stop_reason is 'toolUse'. """ model: str """The name of the model that generated the message.""" - stopReason: StopReason | None = None + stop_reason: StopReason | None = None """ The reason why sampling stopped, if known. 'toolUse' indicates the model wants to use a tool. @@ -1591,45 +1414,36 @@ def content_as_list(self) -> list[SamplingMessageContentBlock]: return self.content if isinstance(self.content, list) else [self.content] -class ResourceTemplateReference(BaseModel): +class ResourceTemplateReference(MCPModel): """A reference to a resource or resource template definition.""" - type: Literal["ref/resource"] + type: Literal["ref/resource"] = "ref/resource" uri: str """The URI or URI template of the resource.""" - model_config = ConfigDict(extra="allow") - -@deprecated("`ResourceReference` is deprecated, you should use `ResourceTemplateReference`.") -class ResourceReference(ResourceTemplateReference): - pass - -class PromptReference(BaseModel): +class PromptReference(MCPModel): """Identifies a prompt.""" - type: Literal["ref/prompt"] + type: Literal["ref/prompt"] = "ref/prompt" name: str - """The name of the prompt or prompt template""" - model_config = ConfigDict(extra="allow") + """The name of the prompt or prompt template.""" -class CompletionArgument(BaseModel): +class CompletionArgument(MCPModel): """The argument's information for completion requests.""" name: str - """The name of the argument""" + """The name of the argument.""" value: str """The value of the argument to use for completion matching.""" - model_config = ConfigDict(extra="allow") -class CompletionContext(BaseModel): +class CompletionContext(MCPModel): """Additional, optional context for completions.""" arguments: dict[str, str] | None = None """Previously-resolved variables in a URI template or prompt.""" - model_config = ConfigDict(extra="allow") class CompleteRequestParams(RequestParams): @@ -1638,8 +1452,7 @@ class CompleteRequestParams(RequestParams): ref: ResourceTemplateReference | PromptReference argument: CompletionArgument context: CompletionContext | None = None - """Additional, optional context for completions""" - model_config = ConfigDict(extra="allow") + """Additional, optional context for completions.""" class CompleteRequest(Request[CompleteRequestParams, Literal["completion/complete"]]): @@ -1649,7 +1462,7 @@ class CompleteRequest(Request[CompleteRequestParams, Literal["completion/complet params: CompleteRequestParams -class Completion(BaseModel): +class Completion(MCPModel): """Completion information.""" values: list[str] @@ -1659,23 +1472,21 @@ class Completion(BaseModel): The total number of completion options available. This can exceed the number of values actually sent in the response. """ - hasMore: bool | None = None + has_more: bool | None = None """ Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown. """ - model_config = ConfigDict(extra="allow") class CompleteResult(Result): - """The server's response to a completion/complete request""" + """The server's response to a completion/complete request.""" completion: Completion class ListRootsRequest(Request[RequestParams | None, Literal["roots/list"]]): - """ - Sent from the server to request a list of root URIs from the client. Roots allow + """Sent from the server to request a list of root URIs from the client. Roots allow servers to ask for specific directories or files to operate on. A common example for roots is providing a set of repositories or directories a server should operate on. @@ -1688,7 +1499,7 @@ class ListRootsRequest(Request[RequestParams | None, Literal["roots/list"]]): params: RequestParams | None = None -class Root(BaseModel): +class Root(MCPModel): """Represents a root directory or file that the server can operate on.""" uri: FileUrl @@ -1703,17 +1514,16 @@ class Root(BaseModel): identifier for the root, which may be useful for display purposes or for referencing the root in other parts of the application. """ - meta: dict[str, Any] | None = Field(alias="_meta", default=None) + meta: Meta | None = Field(alias="_meta", default=None) """ See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) for notes on _meta usage. """ - model_config = ConfigDict(extra="allow") class ListRootsResult(Result): - """ - The client's response to a roots/list request from the server. + """The client's response to a roots/list request from the server. + This result contains an array of Root objects, each representing a root directory or file that the server can operate on. """ @@ -1724,8 +1534,7 @@ class ListRootsResult(Result): class RootsListChangedNotification( Notification[NotificationParams | None, Literal["notifications/roots/list_changed"]] ): - """ - A notification from the client to the server, informing it that the list of + """A notification from the client to the server, informing it that the list of roots has changed. This notification should be sent whenever the client adds, removes, or @@ -1740,7 +1549,7 @@ class RootsListChangedNotification( class CancelledNotificationParams(NotificationParams): """Parameters for cancellation notifications.""" - requestId: RequestId | None = None + request_id: RequestId | None = None """ The ID of the request to cancel. @@ -1751,12 +1560,9 @@ class CancelledNotificationParams(NotificationParams): reason: str | None = None """An optional string describing the reason for the cancellation.""" - model_config = ConfigDict(extra="allow") - class CancelledNotification(Notification[CancelledNotificationParams, Literal["notifications/cancelled"]]): - """ - This notification can be sent by either side to indicate that it is canceling a + """This notification can be sent by either side to indicate that it is canceling a previously-issued request. """ @@ -1767,17 +1573,14 @@ class CancelledNotification(Notification[CancelledNotificationParams, Literal["n class ElicitCompleteNotificationParams(NotificationParams): """Parameters for elicitation completion notifications.""" - elicitationId: str + elicitation_id: str """The unique identifier of the elicitation that was completed.""" - model_config = ConfigDict(extra="allow") - class ElicitCompleteNotification( Notification[ElicitCompleteNotificationParams, Literal["notifications/elicitation/complete"]] ): - """ - A notification from the server to the client, informing it that a URL mode + """A notification from the server to the client, informing it that a URL mode elicitation has been completed. Clients MAY use the notification to automatically retry requests that received a @@ -1790,7 +1593,7 @@ class ElicitCompleteNotification( params: ElicitCompleteNotificationParams -ClientRequestType: TypeAlias = ( +ClientRequest = ( PingRequest | InitializeRequest | CompleteRequest @@ -1809,23 +1612,17 @@ class ElicitCompleteNotification( | ListTasksRequest | CancelTaskRequest ) +client_request_adapter = TypeAdapter[ClientRequest](ClientRequest) -class ClientRequest(RootModel[ClientRequestType]): - pass - - -ClientNotificationType: TypeAlias = ( +ClientNotification = ( CancelledNotification | ProgressNotification | InitializedNotification | RootsListChangedNotification | TaskStatusNotification ) - - -class ClientNotification(RootModel[ClientNotificationType]): - pass +client_notification_adapter = TypeAdapter[ClientNotification](ClientNotification) # Type for elicitation schema - a JSON Schema dict @@ -1846,14 +1643,12 @@ class ElicitRequestFormParams(RequestParams): message: str """The message to present to the user describing what information is being requested.""" - requestedSchema: ElicitRequestedSchema + requested_schema: ElicitRequestedSchema """ - A restricted subset of JSON Schema defining the structure of expected response. + A restricted subset of JSON Schema defining the structure of the expected response. Only top-level properties are allowed, without nesting. """ - model_config = ConfigDict(extra="allow") - class ElicitRequestURLParams(RequestParams): """Parameters for URL mode elicitation requests. @@ -1871,14 +1666,12 @@ class ElicitRequestURLParams(RequestParams): url: str """The URL that the user should navigate to.""" - elicitationId: str - """ - The ID of the elicitation, which must be unique within the context of the server. + elicitation_id: str + """The ID of the elicitation, which must be unique within the context of the server. + The client MUST treat this ID as an opaque value. """ - model_config = ConfigDict(extra="allow") - # Union type for elicitation request parameters ElicitRequestParams: TypeAlias = ElicitRequestURLParams | ElicitRequestFormParams @@ -1906,13 +1699,13 @@ class ElicitResult(Result): content: dict[str, str | int | float | bool | list[str] | None] | None = None """ The submitted form data, only present when action is "accept" in form mode. - Contains values matching the requested schema. Values can be strings, integers, - booleans, or arrays of strings. + Contains values matching the requested schema. Values can be strings, integers, floats, + booleans, arrays of strings, or null. For URL mode, this field is omitted. """ -class ElicitationRequiredErrorData(BaseModel): +class ElicitationRequiredErrorData(MCPModel): """Error data for URLElicitationRequiredError. Servers return this when a request cannot be processed until one or more @@ -1922,12 +1715,11 @@ class ElicitationRequiredErrorData(BaseModel): elicitations: list[ElicitRequestURLParams] """List of URL mode elicitations that must be completed.""" - model_config = ConfigDict(extra="allow") - -ClientResultType: TypeAlias = ( +ClientResult = ( EmptyResult | CreateMessageResult + | CreateMessageResultWithTools | ListRootsResult | ElicitResult | GetTaskResult @@ -1936,13 +1728,10 @@ class ElicitationRequiredErrorData(BaseModel): | CancelTaskResult | CreateTaskResult ) +client_result_adapter = TypeAdapter[ClientResult](ClientResult) -class ClientResult(RootModel[ClientResultType]): - pass - - -ServerRequestType: TypeAlias = ( +ServerRequest = ( PingRequest | CreateMessageRequest | ListRootsRequest @@ -1952,13 +1741,10 @@ class ClientResult(RootModel[ClientResultType]): | ListTasksRequest | CancelTaskRequest ) +server_request_adapter = TypeAdapter[ServerRequest](ServerRequest) -class ServerRequest(RootModel[ServerRequestType]): - pass - - -ServerNotificationType: TypeAlias = ( +ServerNotification = ( CancelledNotification | ProgressNotification | LoggingMessageNotification @@ -1969,13 +1755,10 @@ class ServerRequest(RootModel[ServerRequestType]): | ElicitCompleteNotification | TaskStatusNotification ) +server_notification_adapter = TypeAdapter[ServerNotification](ServerNotification) -class ServerNotification(RootModel[ServerNotificationType]): - pass - - -ServerResultType: TypeAlias = ( +ServerResult = ( EmptyResult | InitializeResult | CompleteResult @@ -1992,7 +1775,4 @@ class ServerNotification(RootModel[ServerNotificationType]): | CancelTaskResult | CreateTaskResult ) - - -class ServerResult(RootModel[ServerResultType]): - pass +server_result_adapter = TypeAdapter[ServerResult](ServerResult) diff --git a/src/mcp/types/jsonrpc.py b/src/mcp/types/jsonrpc.py new file mode 100644 index 000000000..84304a37c --- /dev/null +++ b/src/mcp/types/jsonrpc.py @@ -0,0 +1,83 @@ +"""This module follows the JSON-RPC 2.0 specification: https://www.jsonrpc.org/specification.""" + +from __future__ import annotations + +from typing import Annotated, Any, Literal + +from pydantic import BaseModel, Field, TypeAdapter + +RequestId = Annotated[int, Field(strict=True)] | str +"""The ID of a JSON-RPC request.""" + + +class JSONRPCRequest(BaseModel): + """A JSON-RPC request that expects a response.""" + + jsonrpc: Literal["2.0"] + id: RequestId + method: str + params: dict[str, Any] | None = None + + +class JSONRPCNotification(BaseModel): + """A JSON-RPC notification which does not expect a response.""" + + jsonrpc: Literal["2.0"] + method: str + params: dict[str, Any] | None = None + + +# TODO(Marcelo): This is actually not correct. A JSONRPCResponse is the union of a successful response and an error. +class JSONRPCResponse(BaseModel): + """A successful (non-error) response to a request.""" + + jsonrpc: Literal["2.0"] + id: RequestId + result: dict[str, Any] + + +# MCP-specific error codes in the range [-32000, -32099] +URL_ELICITATION_REQUIRED = -32042 +"""Error code indicating that a URL mode elicitation is required before the request can be processed.""" + +# SDK error codes +CONNECTION_CLOSED = -32000 +REQUEST_TIMEOUT = -32001 + +# Standard JSON-RPC error codes +PARSE_ERROR = -32700 +INVALID_REQUEST = -32600 +METHOD_NOT_FOUND = -32601 +INVALID_PARAMS = -32602 +INTERNAL_ERROR = -32603 + + +class ErrorData(BaseModel): + """Error information for JSON-RPC error responses.""" + + code: int + """The error type that occurred.""" + + message: str + """A short description of the error. + + The message SHOULD be limited to a concise single sentence. + """ + + data: Any = None + """Additional information about the error. + + The value of this member is defined by the sender (e.g. detailed error information, nested errors, etc.). + """ + + +class JSONRPCError(BaseModel): + """A response to a request that indicates an error occurred.""" + + jsonrpc: Literal["2.0"] + id: RequestId | None + error: ErrorData + + +JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError +jsonrpc_message_adapter: TypeAdapter[JSONRPCMessage] = TypeAdapter(JSONRPCMessage) diff --git a/tests/client/auth/extensions/test_client_credentials.py b/tests/client/auth/extensions/test_client_credentials.py index 6d134af74..0003b1679 100644 --- a/tests/client/auth/extensions/test_client_credentials.py +++ b/tests/client/auth/extensions/test_client_credentials.py @@ -1,4 +1,5 @@ import urllib.parse +import warnings import jwt import pytest @@ -22,7 +23,7 @@ def __init__(self): self._tokens: OAuthToken | None = None self._client_info: OAuthClientInformationFull | None = None - async def get_tokens(self) -> OAuthToken | None: # pragma: no cover + async def get_tokens(self) -> OAuthToken | None: return self._tokens async def set_tokens(self, tokens: OAuthToken) -> None: # pragma: no cover @@ -60,8 +61,6 @@ async def callback_handler() -> tuple[str, str | None]: # pragma: no cover """Mock callback handler.""" return "test_auth_code", "test_state" - import warnings - with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) return RFC7523OAuthClientProvider( diff --git a/tests/client/conftest.py b/tests/client/conftest.py index 1e5c4d524..2e39f1363 100644 --- a/tests/client/conftest.py +++ b/tests/client/conftest.py @@ -40,38 +40,36 @@ def clear(self) -> None: self.client.sent_messages.clear() self.server.sent_messages.clear() - def get_client_requests(self, method: str | None = None) -> list[JSONRPCRequest]: # pragma: no cover + def get_client_requests(self, method: str | None = None) -> list[JSONRPCRequest]: """Get client-sent requests, optionally filtered by method.""" return [ - req.message.root + req.message for req in self.client.sent_messages - if isinstance(req.message.root, JSONRPCRequest) and (method is None or req.message.root.method == method) + if isinstance(req.message, JSONRPCRequest) and (method is None or req.message.method == method) ] def get_server_requests(self, method: str | None = None) -> list[JSONRPCRequest]: # pragma: no cover """Get server-sent requests, optionally filtered by method.""" return [ # pragma: no cover - req.message.root + req.message for req in self.server.sent_messages - if isinstance(req.message.root, JSONRPCRequest) and (method is None or req.message.root.method == method) + if isinstance(req.message, JSONRPCRequest) and (method is None or req.message.method == method) ] def get_client_notifications(self, method: str | None = None) -> list[JSONRPCNotification]: # pragma: no cover """Get client-sent notifications, optionally filtered by method.""" return [ - notif.message.root + notif.message for notif in self.client.sent_messages - if isinstance(notif.message.root, JSONRPCNotification) - and (method is None or notif.message.root.method == method) + if isinstance(notif.message, JSONRPCNotification) and (method is None or notif.message.method == method) ] def get_server_notifications(self, method: str | None = None) -> list[JSONRPCNotification]: # pragma: no cover """Get server-sent notifications, optionally filtered by method.""" return [ - notif.message.root + notif.message for notif in self.server.sent_messages - if isinstance(notif.message.root, JSONRPCNotification) - and (method is None or notif.message.root.method == method) + if isinstance(notif.message, JSONRPCNotification) and (method is None or notif.message.method == method) ] @@ -79,7 +77,8 @@ def get_server_notifications(self, method: str | None = None) -> list[JSONRPCNot def stream_spy() -> Generator[Callable[[], StreamSpyCollection], None, None]: """Fixture that provides spies for both client and server write streams. - Example usage: + Example: + ```python async def test_something(stream_spy): # ... set up server and client ... @@ -94,6 +93,7 @@ async def test_something(stream_spy): # Clear for the next operation spies.clear() + ``` """ client_spy = None server_spy = None @@ -123,11 +123,13 @@ async def patched_create_streams(): yield (client_read, spy_client_write), (server_read, spy_server_write) # Apply the patch for the duration of the test + # Patch both locations since InMemoryTransport imports it directly with patch("mcp.shared.memory.create_client_server_memory_streams", patched_create_streams): - # Return a collection with helper methods - def get_spy_collection() -> StreamSpyCollection: - assert client_spy is not None, "client_spy was not initialized" - assert server_spy is not None, "server_spy was not initialized" - return StreamSpyCollection(client_spy, server_spy) - - yield get_spy_collection + with patch("mcp.client._memory.create_client_server_memory_streams", patched_create_streams): + # Return a collection with helper methods + def get_spy_collection() -> StreamSpyCollection: + assert client_spy is not None, "client_spy was not initialized" + assert server_spy is not None, "server_spy was not initialized" + return StreamSpyCollection(client_spy, server_spy) + + yield get_spy_collection diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 593d5cfe0..5aa985e36 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -1,11 +1,9 @@ -""" -Tests for refactored OAuth client authentication implementation. -""" +"""Tests for refactored OAuth client authentication implementation.""" import base64 import time from unittest import mock -from urllib.parse import unquote +from urllib.parse import parse_qs, quote, unquote, urlparse import httpx import pytest @@ -13,6 +11,7 @@ from pydantic import AnyHttpUrl, AnyUrl from mcp.client.auth import OAuthClientProvider, PKCEParameters +from mcp.client.auth.exceptions import OAuthFlowError from mcp.client.auth.utils import ( build_oauth_authorization_server_metadata_discovery_urls, build_protected_resource_metadata_discovery_urls, @@ -27,6 +26,8 @@ is_valid_client_metadata_url, should_use_client_metadata_url, ) +from mcp.server.auth.routes import build_metadata +from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions from mcp.shared.auth import ( OAuthClientInformationFull, OAuthClientMetadata, @@ -758,8 +759,6 @@ async def test_resource_param_included_with_recent_protocol_version(self, oauth_ content = request.content.decode() assert "resource=" in content # Check URL-encoded resource parameter - from urllib.parse import quote - expected_resource = quote(oauth_provider.context.get_resource_url(), safe="") assert f"resource={expected_resource}" in content @@ -820,6 +819,136 @@ async def test_resource_param_included_with_protected_resource_metadata(self, oa assert "resource=" in content +@pytest.mark.anyio +async def test_validate_resource_rejects_mismatched_resource( + client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage +) -> None: + """Client must reject PRM resource that doesn't match server URL.""" + provider = OAuthClientProvider( + server_url="https://api.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + ) + provider._initialized = True + + prm = ProtectedResourceMetadata( + resource=AnyHttpUrl("https://evil.example.com/mcp"), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + ) + with pytest.raises(OAuthFlowError, match="does not match expected"): + await provider._validate_resource_match(prm) + + +@pytest.mark.anyio +async def test_validate_resource_accepts_matching_resource( + client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage +) -> None: + """Client must accept PRM resource that matches server URL.""" + provider = OAuthClientProvider( + server_url="https://api.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + ) + provider._initialized = True + + prm = ProtectedResourceMetadata( + resource=AnyHttpUrl("https://api.example.com/v1/mcp"), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + ) + # Should not raise + await provider._validate_resource_match(prm) + + +@pytest.mark.anyio +async def test_validate_resource_custom_callback( + client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage +) -> None: + """Custom callback overrides default validation.""" + callback_called_with: list[tuple[str, str | None]] = [] + + async def custom_validate(server_url: str, prm_resource: str | None) -> None: + callback_called_with.append((server_url, prm_resource)) + + provider = OAuthClientProvider( + server_url="https://api.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + validate_resource_url=custom_validate, + ) + provider._initialized = True + + # This would normally fail default validation (different origin), + # but custom callback accepts it + prm = ProtectedResourceMetadata( + resource=AnyHttpUrl("https://evil.example.com/mcp"), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + ) + await provider._validate_resource_match(prm) + assert callback_called_with == snapshot([("https://api.example.com/v1/mcp", "https://evil.example.com/mcp")]) + + +@pytest.mark.anyio +async def test_validate_resource_accepts_root_url_with_trailing_slash( + client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage +) -> None: + """Root URLs with trailing slash normalization should match.""" + provider = OAuthClientProvider( + server_url="https://api.example.com", + client_metadata=client_metadata, + storage=mock_storage, + ) + provider._initialized = True + + prm = ProtectedResourceMetadata( + resource=AnyHttpUrl("https://api.example.com/"), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + ) + # Should not raise despite trailing slash difference + await provider._validate_resource_match(prm) + + +@pytest.mark.anyio +async def test_validate_resource_accepts_server_url_with_trailing_slash( + client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage +) -> None: + """Server URL with trailing slash should match PRM resource.""" + provider = OAuthClientProvider( + server_url="https://api.example.com/v1/mcp/", + client_metadata=client_metadata, + storage=mock_storage, + ) + provider._initialized = True + + prm = ProtectedResourceMetadata( + resource=AnyHttpUrl("https://api.example.com/v1/mcp"), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + ) + # Should not raise - both normalize to the same URL with trailing slash + await provider._validate_resource_match(prm) + + +@pytest.mark.anyio +async def test_get_resource_url_uses_canonical_when_prm_mismatches( + client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage +) -> None: + """get_resource_url falls back to canonical URL when PRM resource doesn't match.""" + provider = OAuthClientProvider( + server_url="https://api.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + ) + provider._initialized = True + + # Set PRM with a resource that is NOT a parent of the server URL + provider.context.protected_resource_metadata = ProtectedResourceMetadata( + resource=AnyHttpUrl("https://other.example.com/mcp"), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + ) + + # get_resource_url should return the canonical server URL, not the PRM resource + assert provider.context.get_resource_url() == snapshot("https://api.example.com/v1/mcp") + + class TestRegistrationResponse: """Test client registration response handling.""" @@ -965,7 +1094,7 @@ async def test_auth_flow_with_no_tokens(self, oauth_provider: OAuthClientProvide # Send a successful discovery response with minimal protected resource metadata discovery_response = httpx.Response( 200, - content=b'{"resource": "https://api.example.com/mcp", "authorization_servers": ["https://auth.example.com"]}', + content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com"]}', request=discovery_request, ) @@ -1081,6 +1210,116 @@ async def test_auth_flow_no_unnecessary_retry_after_oauth( # Verify exactly one request was yielded (no double-sending) assert request_yields == 1, f"Expected 1 request yield, got {request_yields}" + @pytest.mark.anyio + async def test_token_exchange_accepts_201_status( + self, oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage + ): + """Test that token exchange accepts both 200 and 201 status codes.""" + # Ensure no tokens are stored + oauth_provider.context.current_tokens = None + oauth_provider.context.token_expiry_time = None + oauth_provider._initialized = True + + # Create a test request + test_request = httpx.Request("GET", "https://api.example.com/mcp") + + # Mock the auth flow + auth_flow = oauth_provider.async_auth_flow(test_request) + + # First request should be the original request without auth header + request = await auth_flow.__anext__() + assert "Authorization" not in request.headers + + # Send a 401 response to trigger the OAuth flow + response = httpx.Response( + 401, + headers={ + "WWW-Authenticate": 'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"' + }, + request=test_request, + ) + + # Next request should be to discover protected resource metadata + discovery_request = await auth_flow.asend(response) + assert discovery_request.method == "GET" + assert str(discovery_request.url) == "https://api.example.com/.well-known/oauth-protected-resource" + + # Send a successful discovery response with minimal protected resource metadata + discovery_response = httpx.Response( + 200, + content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com"]}', + request=discovery_request, + ) + + # Next request should be to discover OAuth metadata + oauth_metadata_request = await auth_flow.asend(discovery_response) + assert oauth_metadata_request.method == "GET" + assert str(oauth_metadata_request.url).startswith("https://auth.example.com/") + assert "mcp-protocol-version" in oauth_metadata_request.headers + + # Send a successful OAuth metadata response + oauth_metadata_response = httpx.Response( + 200, + content=( + b'{"issuer": "https://auth.example.com", ' + b'"authorization_endpoint": "https://auth.example.com/authorize", ' + b'"token_endpoint": "https://auth.example.com/token", ' + b'"registration_endpoint": "https://auth.example.com/register"}' + ), + request=oauth_metadata_request, + ) + + # Next request should be to register client + registration_request = await auth_flow.asend(oauth_metadata_response) + assert registration_request.method == "POST" + assert str(registration_request.url) == "https://auth.example.com/register" + + # Send a successful registration response with 201 status + registration_response = httpx.Response( + 201, + content=b'{"client_id": "test_client_id", "client_secret": "test_client_secret", "redirect_uris": ["http://localhost:3030/callback"]}', + request=registration_request, + ) + + # Mock the authorization process + oauth_provider._perform_authorization_code_grant = mock.AsyncMock( + return_value=("test_auth_code", "test_code_verifier") + ) + + # Next request should be to exchange token + token_request = await auth_flow.asend(registration_response) + assert token_request.method == "POST" + assert str(token_request.url) == "https://auth.example.com/token" + assert "code=test_auth_code" in token_request.content.decode() + + # Send a successful token response with 201 status code (test both 200 and 201 are accepted) + token_response = httpx.Response( + 201, + content=( + b'{"access_token": "new_access_token", "token_type": "Bearer", "expires_in": 3600, ' + b'"refresh_token": "new_refresh_token"}' + ), + request=token_request, + ) + + # Final request should be the original request with auth header + final_request = await auth_flow.asend(token_response) + assert final_request.headers["Authorization"] == "Bearer new_access_token" + assert final_request.method == "GET" + assert str(final_request.url) == "https://api.example.com/mcp" + + # Send final success response to properly close the generator + final_response = httpx.Response(200, request=final_request) + try: + await auth_flow.asend(final_response) + except StopAsyncIteration: + pass # Expected - generator should complete + + # Verify tokens were stored + assert oauth_provider.context.current_tokens is not None + assert oauth_provider.context.current_tokens.access_token == "new_access_token" + assert oauth_provider.context.token_expiry_time is not None + @pytest.mark.anyio async def test_403_insufficient_scope_updates_scope_from_header( self, @@ -1116,8 +1355,6 @@ async def capture_redirect(url: str) -> None: "%3A", ":" ).replace("+", " ") # Extract state from redirect URL - from urllib.parse import parse_qs, urlparse - parsed = urlparse(url) params = parse_qs(parsed.query) captured_state = params.get("state", [None])[0] @@ -1226,9 +1463,6 @@ def test_build_metadata( registration_endpoint: str, revocation_endpoint: str, ): - from mcp.server.auth.routes import build_metadata - from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions - metadata = build_metadata( issuer_url=AnyHttpUrl(issuer_url), service_documentation_url=AnyHttpUrl(service_documentation_url), @@ -1593,7 +1827,7 @@ async def callback_handler() -> tuple[str, str | None]: final_response = httpx.Response(200, request=final_request) try: await auth_flow.asend(final_response) - except StopAsyncIteration: # pragma: no cover + except StopAsyncIteration: pass @pytest.mark.anyio diff --git a/tests/client/test_client.py b/tests/client/test_client.py new file mode 100644 index 000000000..45300063a --- /dev/null +++ b/tests/client/test_client.py @@ -0,0 +1,306 @@ +"""Tests for the unified Client class.""" + +from __future__ import annotations + +from unittest.mock import patch + +import anyio +import pytest +from inline_snapshot import snapshot + +from mcp import types +from mcp.client._memory import InMemoryTransport +from mcp.client.client import Client +from mcp.server import Server, ServerRequestContext +from mcp.server.mcpserver import MCPServer +from mcp.types import ( + CallToolResult, + EmptyResult, + GetPromptResult, + ListPromptsResult, + ListResourcesResult, + ListResourceTemplatesResult, + ListToolsResult, + Prompt, + PromptArgument, + PromptMessage, + PromptsCapability, + ReadResourceResult, + Resource, + ResourcesCapability, + ServerCapabilities, + TextContent, + TextResourceContents, + Tool, + ToolsCapability, +) + +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def simple_server() -> Server: + """Create a simple MCP server for testing.""" + + async def handle_list_resources( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> ListResourcesResult: + return ListResourcesResult( + resources=[Resource(uri="memory://test", name="Test Resource", description="A test resource")] + ) + + async def handle_subscribe_resource(ctx: ServerRequestContext, params: types.SubscribeRequestParams) -> EmptyResult: + return EmptyResult() + + async def handle_unsubscribe_resource( + ctx: ServerRequestContext, params: types.UnsubscribeRequestParams + ) -> EmptyResult: + return EmptyResult() + + async def handle_set_logging_level(ctx: ServerRequestContext, params: types.SetLevelRequestParams) -> EmptyResult: + return EmptyResult() + + async def handle_completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> types.CompleteResult: + return types.CompleteResult(completion=types.Completion(values=[])) + + return Server( + name="test_server", + on_list_resources=handle_list_resources, + on_subscribe_resource=handle_subscribe_resource, + on_unsubscribe_resource=handle_unsubscribe_resource, + on_set_logging_level=handle_set_logging_level, + on_completion=handle_completion, + ) + + +@pytest.fixture +def app() -> MCPServer: + """Create an MCPServer server for testing.""" + server = MCPServer("test") + + @server.tool() + def greet(name: str) -> str: + """Greet someone by name.""" + return f"Hello, {name}!" + + @server.resource("test://resource") + def test_resource() -> str: + """A test resource.""" + return "Test content" + + @server.prompt() + def greeting_prompt(name: str) -> str: + """A greeting prompt.""" + return f"Please greet {name} warmly." + + return server + + +async def test_client_is_initialized(app: MCPServer): + """Test that the client is initialized after entering context.""" + async with Client(app) as client: + assert client.server_capabilities == snapshot( + ServerCapabilities( + experimental={}, + prompts=PromptsCapability(list_changed=False), + resources=ResourcesCapability(subscribe=False, list_changed=False), + tools=ToolsCapability(list_changed=False), + ) + ) + + +async def test_client_with_simple_server(simple_server: Server): + """Test that from_server works with a basic Server instance.""" + async with Client(simple_server) as client: + resources = await client.list_resources() + assert resources == snapshot( + ListResourcesResult( + resources=[Resource(name="Test Resource", uri="memory://test", description="A test resource")] + ) + ) + + +async def test_client_send_ping(app: MCPServer): + async with Client(app) as client: + result = await client.send_ping() + assert result == snapshot(EmptyResult()) + + +async def test_client_list_tools(app: MCPServer): + async with Client(app) as client: + result = await client.list_tools() + assert result == snapshot( + ListToolsResult( + tools=[ + Tool( + name="greet", + description="Greet someone by name.", + input_schema={ + "properties": {"name": {"title": "Name", "type": "string"}}, + "required": ["name"], + "title": "greetArguments", + "type": "object", + }, + output_schema={ + "properties": {"result": {"title": "Result", "type": "string"}}, + "required": ["result"], + "title": "greetOutput", + "type": "object", + }, + ) + ] + ) + ) + + +async def test_client_call_tool(app: MCPServer): + async with Client(app) as client: + result = await client.call_tool("greet", {"name": "World"}) + assert result == snapshot( + CallToolResult( + content=[TextContent(text="Hello, World!")], + structured_content={"result": "Hello, World!"}, + ) + ) + + +async def test_read_resource(app: MCPServer): + """Test reading a resource.""" + async with Client(app) as client: + result = await client.read_resource("test://resource") + assert result == snapshot( + ReadResourceResult( + contents=[TextResourceContents(uri="test://resource", mime_type="text/plain", text="Test content")] + ) + ) + + +async def test_get_prompt(app: MCPServer): + """Test getting a prompt.""" + async with Client(app) as client: + result = await client.get_prompt("greeting_prompt", {"name": "Alice"}) + assert result == snapshot( + GetPromptResult( + description="A greeting prompt.", + messages=[PromptMessage(role="user", content=TextContent(text="Please greet Alice warmly."))], + ) + ) + + +def test_client_session_property_before_enter(app: MCPServer): + """Test that accessing session before context manager raises RuntimeError.""" + client = Client(app) + with pytest.raises(RuntimeError, match="Client must be used within an async context manager"): + client.session + + +async def test_client_reentry_raises_runtime_error(app: MCPServer): + """Test that reentering a client raises RuntimeError.""" + async with Client(app) as client: + with pytest.raises(RuntimeError, match="Client is already entered"): + await client.__aenter__() + + +async def test_client_send_progress_notification(): + """Test sending progress notification.""" + received_from_client = None + event = anyio.Event() + + async def handle_progress(ctx: ServerRequestContext, params: types.ProgressNotificationParams) -> None: + nonlocal received_from_client + received_from_client = {"progress_token": params.progress_token, "progress": params.progress} + event.set() + + server = Server(name="test_server", on_progress=handle_progress) + + async with Client(server) as client: + await client.send_progress_notification(progress_token="token123", progress=50.0) + await event.wait() + assert received_from_client == snapshot({"progress_token": "token123", "progress": 50.0}) + + +async def test_client_subscribe_resource(simple_server: Server): + async with Client(simple_server) as client: + result = await client.subscribe_resource("memory://test") + assert result == snapshot(EmptyResult()) + + +async def test_client_unsubscribe_resource(simple_server: Server): + async with Client(simple_server) as client: + result = await client.unsubscribe_resource("memory://test") + assert result == snapshot(EmptyResult()) + + +async def test_client_set_logging_level(simple_server: Server): + """Test setting logging level.""" + async with Client(simple_server) as client: + result = await client.set_logging_level("debug") + assert result == snapshot(EmptyResult()) + + +async def test_client_list_resources_with_params(app: MCPServer): + """Test listing resources with params parameter.""" + async with Client(app) as client: + result = await client.list_resources() + assert result == snapshot( + ListResourcesResult( + resources=[ + Resource( + name="test_resource", + uri="test://resource", + description="A test resource.", + mime_type="text/plain", + ) + ] + ) + ) + + +async def test_client_list_resource_templates(app: MCPServer): + """Test listing resource templates with params parameter.""" + async with Client(app) as client: + result = await client.list_resource_templates() + assert result == snapshot(ListResourceTemplatesResult(resource_templates=[])) + + +async def test_list_prompts(app: MCPServer): + """Test listing prompts with params parameter.""" + async with Client(app) as client: + result = await client.list_prompts() + assert result == snapshot( + ListPromptsResult( + prompts=[ + Prompt( + name="greeting_prompt", + description="A greeting prompt.", + arguments=[PromptArgument(name="name", required=True)], + ) + ] + ) + ) + + +async def test_complete_with_prompt_reference(simple_server: Server): + """Test getting completions for a prompt argument.""" + async with Client(simple_server) as client: + ref = types.PromptReference(type="ref/prompt", name="test_prompt") + result = await client.complete(ref=ref, argument={"name": "arg", "value": "test"}) + assert result == snapshot(types.CompleteResult(completion=types.Completion(values=[]))) + + +def test_client_with_url_initializes_streamable_http_transport(): + with patch("mcp.client.client.streamable_http_client") as mock: + _ = Client("http://localhost:8000/mcp") + mock.assert_called_once_with("http://localhost:8000/mcp") + + +async def test_client_uses_transport_directly(app: MCPServer): + transport = InMemoryTransport(app) + async with Client(transport) as client: + result = await client.call_tool("greet", {"name": "Transport"}) + assert result == snapshot( + CallToolResult( + content=[TextContent(text="Hello, Transport!")], + structured_content={"result": "Hello, Transport!"}, + ) + ) diff --git a/tests/client/test_http_unicode.py b/tests/client/test_http_unicode.py index 95e01ce57..cc2e14e46 100644 --- a/tests/client/test_http_unicode.py +++ b/tests/client/test_http_unicode.py @@ -1,5 +1,4 @@ -""" -Tests for Unicode handling in streamable HTTP transport. +"""Tests for Unicode handling in streamable HTTP transport. Verifies that Unicode text is correctly transmitted and received in both directions (server→client and client→server) using the streamable HTTP transport. @@ -7,12 +6,19 @@ import multiprocessing import socket -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator +from contextlib import asynccontextmanager import pytest +from starlette.applications import Starlette +from starlette.routing import Mount +from mcp import types from mcp.client.session import ClientSession -from mcp.client.streamable_http import streamablehttp_client +from mcp.client.streamable_http import streamable_http_client +from mcp.server import Server, ServerRequestContext +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from mcp.types import TextContent, Tool from tests.test_helpers import wait_for_server # Test constants with various Unicode characters @@ -37,69 +43,59 @@ def run_unicode_server(port: int) -> None: # pragma: no cover """Run the Unicode test server in a separate process.""" - # Import inside the function since this runs in a separate process - from collections.abc import AsyncGenerator - from contextlib import asynccontextmanager - from typing import Any - import uvicorn - from starlette.applications import Starlette - from starlette.routing import Mount - - import mcp.types as types - from mcp.server import Server - from mcp.server.streamable_http_manager import StreamableHTTPSessionManager - from mcp.types import TextContent, Tool # Need to recreate the server setup in this process - server = Server(name="unicode_test_server") - - @server.list_tools() - async def list_tools() -> list[Tool]: - """List tools with Unicode descriptions.""" - return [ - Tool( - name="echo_unicode", - description="🔤 Echo Unicode text - Hello 👋 World 🌍 - Testing 🧪 Unicode ✨", - inputSchema={ - "type": "object", - "properties": { - "text": {"type": "string", "description": "Text to echo back"}, + async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + Tool( + name="echo_unicode", + description="🔤 Echo Unicode text - Hello 👋 World 🌍 - Testing 🧪 Unicode ✨", + input_schema={ + "type": "object", + "properties": { + "text": {"type": "string", "description": "Text to echo back"}, + }, + "required": ["text"], }, - "required": ["text"], - }, - ), - ] - - @server.call_tool() - async def call_tool(name: str, arguments: dict[str, Any] | None) -> list[TextContent]: - """Handle tool calls with Unicode content.""" - if name == "echo_unicode": - text = arguments.get("text", "") if arguments else "" - return [ - TextContent( - type="text", - text=f"Echo: {text}", - ) + ), ] - else: - raise ValueError(f"Unknown tool: {name}") - - @server.list_prompts() - async def list_prompts() -> list[types.Prompt]: - """List prompts with Unicode names and descriptions.""" - return [ - types.Prompt( - name="unicode_prompt", - description="Unicode prompt - Слой хранилища, где располагаются", - arguments=[], + ) + + async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + if params.name == "echo_unicode": + text = params.arguments.get("text", "") if params.arguments else "" + return types.CallToolResult( + content=[ + TextContent( + type="text", + text=f"Echo: {text}", + ) + ] ) - ] + else: + raise ValueError(f"Unknown tool: {params.name}") + + async def handle_list_prompts( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListPromptsResult: + return types.ListPromptsResult( + prompts=[ + types.Prompt( + name="unicode_prompt", + description="Unicode prompt - Слой хранилища, где располагаются", + arguments=[], + ) + ] + ) - @server.get_prompt() - async def get_prompt(name: str, arguments: dict[str, Any] | None) -> types.GetPromptResult: - """Get a prompt with Unicode content.""" - if name == "unicode_prompt": + async def handle_get_prompt( + ctx: ServerRequestContext, params: types.GetPromptRequestParams + ) -> types.GetPromptResult: + if params.name == "unicode_prompt": return types.GetPromptResult( messages=[ types.PromptMessage( @@ -111,7 +107,15 @@ async def get_prompt(name: str, arguments: dict[str, Any] | None) -> types.GetPr ) ] ) - raise ValueError(f"Unknown prompt: {name}") + raise ValueError(f"Unknown prompt: {params.name}") + + server = Server( + name="unicode_test_server", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, + on_list_prompts=handle_list_prompts, + on_get_prompt=handle_get_prompt, + ) # Create the session manager session_manager = StreamableHTTPSessionManager( @@ -178,7 +182,7 @@ async def test_streamable_http_client_unicode_tool_call(running_unicode_server: base_url = running_unicode_server endpoint_url = f"{base_url}/mcp" - async with streamablehttp_client(endpoint_url) as (read_stream, write_stream, _get_session_id): + async with streamable_http_client(endpoint_url) as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: await session.initialize() @@ -210,7 +214,7 @@ async def test_streamable_http_client_unicode_prompts(running_unicode_server: st base_url = running_unicode_server endpoint_url = f"{base_url}/mcp" - async with streamablehttp_client(endpoint_url) as (read_stream, write_stream, _get_session_id): + async with streamable_http_client(endpoint_url) as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: await session.initialize() diff --git a/tests/client/test_list_methods_cursor.py b/tests/client/test_list_methods_cursor.py index 94a72c34e..f70fb9277 100644 --- a/tests/client/test_list_methods_cursor.py +++ b/tests/client/test_list_methods_cursor.py @@ -2,11 +2,10 @@ import pytest -import mcp.types as types -from mcp.server import Server -from mcp.server.fastmcp import FastMCP -from mcp.shared.memory import create_connected_server_and_client_session as create_session -from mcp.types import ListToolsRequest, ListToolsResult +from mcp import Client, types +from mcp.server import Server, ServerRequestContext +from mcp.server.mcpserver import MCPServer +from mcp.types import ListToolsResult from .conftest import StreamSpyCollection @@ -16,32 +15,30 @@ @pytest.fixture async def full_featured_server(): """Create a server with tools, resources, prompts, and templates.""" - server = FastMCP("test") - - @server.tool(name="test_tool_1") - async def test_tool_1() -> str: # pragma: no cover - """First test tool""" - return "Result 1" + server = MCPServer("test") + + # pragma: no cover on handlers below - these exist only to register items with the + # server so list_* methods return results. The handlers themselves are never called + # because these tests only verify pagination/cursor behavior, not tool/resource invocation. + @server.tool() + def greet(name: str) -> str: # pragma: no cover + """Greet someone by name.""" + return f"Hello, {name}!" - @server.tool(name="test_tool_2") - async def test_tool_2() -> str: # pragma: no cover - """Second test tool""" - return "Result 2" + @server.resource("test://resource") + def test_resource() -> str: # pragma: no cover + """A test resource.""" + return "Test content" - @server.resource("resource://test/data") - async def test_resource() -> str: # pragma: no cover - """Test resource""" - return "Test data" + @server.resource("test://template/{id}") + def test_template(id: str) -> str: # pragma: no cover + """A test resource template.""" + return f"Template content for {id}" @server.prompt() - async def test_prompt(name: str) -> str: # pragma: no cover - """Test prompt""" - return f"Hello, {name}!" - - @server.resource("resource://test/{name}") - async def test_template(name: str) -> str: # pragma: no cover - """Test resource template""" - return f"Data for {name}" + def greeting_prompt(name: str) -> str: # pragma: no cover + """A greeting prompt.""" + return f"Please greet {name}." return server @@ -55,174 +52,72 @@ async def test_template(name: str) -> str: # pragma: no cover ("list_resource_templates", "resources/templates/list"), ], ) -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -async def test_list_methods_cursor_parameter( +async def test_list_methods_params_parameter( stream_spy: Callable[[], StreamSpyCollection], - full_featured_server: FastMCP, + full_featured_server: MCPServer, method_name: str, request_method: str, ): - """Test that the cursor parameter is accepted and correctly passed to the server. + """Test that the params parameter is accepted and correctly passed to the server. Covers: list_tools, list_resources, list_prompts, list_resource_templates See: https://modelcontextprotocol.io/specification/2025-03-26/server/utilities/pagination#request-format """ - async with create_session(full_featured_server._mcp_server) as client_session: + async with Client(full_featured_server) as client: spies = stream_spy() - # Test without cursor parameter (omitted) - method = getattr(client_session, method_name) + # Test without params (omitted) + method = getattr(client, method_name) _ = await method() requests = spies.get_client_requests(method=request_method) assert len(requests) == 1 - assert requests[0].params is None + assert requests[0].params is None or "cursor" not in requests[0].params spies.clear() - # Test with cursor=None - _ = await method(cursor=None) - requests = spies.get_client_requests(method=request_method) - assert len(requests) == 1 - assert requests[0].params is None - - spies.clear() - - # Test with cursor as string - _ = await method(cursor="some_cursor_value") + # Test with params containing cursor + _ = await method(cursor="from_params") requests = spies.get_client_requests(method=request_method) assert len(requests) == 1 assert requests[0].params is not None - assert requests[0].params["cursor"] == "some_cursor_value" + assert requests[0].params["cursor"] == "from_params" spies.clear() - # Test with empty string cursor - _ = await method(cursor="") - requests = spies.get_client_requests(method=request_method) - assert len(requests) == 1 - assert requests[0].params is not None - assert requests[0].params["cursor"] == "" - - -@pytest.mark.parametrize( - "method_name,request_method", - [ - ("list_tools", "tools/list"), - ("list_resources", "resources/list"), - ("list_prompts", "prompts/list"), - ("list_resource_templates", "resources/templates/list"), - ], -) -async def test_list_methods_params_parameter( - stream_spy: Callable[[], StreamSpyCollection], - full_featured_server: FastMCP, - method_name: str, - request_method: str, -): - """Test that the params parameter works correctly for list methods. - - Covers: list_tools, list_resources, list_prompts, list_resource_templates - - This tests the new params parameter API (non-deprecated) to ensure - it correctly handles all parameter combinations. - """ - async with create_session(full_featured_server._mcp_server) as client_session: - spies = stream_spy() - method = getattr(client_session, method_name) - - # Test without params parameter (omitted) + # Test with empty params _ = await method() requests = spies.get_client_requests(method=request_method) assert len(requests) == 1 - assert requests[0].params is None - - spies.clear() - - # Test with params=None - _ = await method(params=None) - requests = spies.get_client_requests(method=request_method) - assert len(requests) == 1 - assert requests[0].params is None - - spies.clear() - - # Test with empty params (for strict servers) - _ = await method(params=types.PaginatedRequestParams()) - requests = spies.get_client_requests(method=request_method) - assert len(requests) == 1 - assert requests[0].params is not None - assert requests[0].params.get("cursor") is None - - spies.clear() - - # Test with params containing cursor - _ = await method(params=types.PaginatedRequestParams(cursor="some_cursor_value")) - requests = spies.get_client_requests(method=request_method) - assert len(requests) == 1 - assert requests[0].params is not None - assert requests[0].params["cursor"] == "some_cursor_value" + # Empty params means no cursor + assert requests[0].params is None or "cursor" not in requests[0].params -@pytest.mark.parametrize( - "method_name", - [ - "list_tools", - "list_resources", - "list_prompts", - "list_resource_templates", - ], -) -async def test_list_methods_raises_error_when_both_cursor_and_params_provided( - full_featured_server: FastMCP, - method_name: str, +async def test_list_tools_with_strict_server_validation( + full_featured_server: MCPServer, ): - """Test that providing both cursor and params raises ValueError. + """Test pagination with a server that validates request format strictly.""" + async with Client(full_featured_server) as client: + result = await client.list_tools() + assert isinstance(result, ListToolsResult) + assert len(result.tools) > 0 - Covers: list_tools, list_resources, list_prompts, list_resource_templates - - When both cursor and params are provided, a ValueError should be raised - to prevent ambiguity. - """ - async with create_session(full_featured_server._mcp_server) as client_session: - method = getattr(client_session, method_name) - - # Call with both cursor and params - should raise ValueError - with pytest.raises(ValueError, match="Cannot specify both cursor and params"): - await method( - cursor="old_cursor", - params=types.PaginatedRequestParams(cursor="new_cursor"), - ) - - -async def test_list_tools_with_strict_server_validation(): - """Test that list_tools works with strict servers require a params field, - even if it is empty. - - Some MCP servers may implement strict JSON-RPC validation that requires - the params field to always be present in requests, even if empty {}. - - This test ensures such servers are supported by the client SDK for list_resources - requests without a cursor. - """ - server = Server("strict_server") +async def test_list_tools_with_lowlevel_server(): + """Test that list_tools works with a lowlevel Server using params.""" - @server.list_tools() - async def handle_list_tools(request: ListToolsRequest) -> ListToolsResult: # pragma: no cover - """Strict handler that validates params field exists""" + async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> ListToolsResult: + # Echo back what cursor we received in the tool description + cursor = params.cursor if params else None + return ListToolsResult(tools=[types.Tool(name="test_tool", description=f"cursor={cursor}", input_schema={})]) - # Simulate strict server validation - if request.params is None: - raise ValueError( - "Strict server validation failed: params field must be present. " - "Expected params: {} for requests without cursor." - ) + server = Server("test-lowlevel", on_list_tools=handle_list_tools) - # Return empty tools list - return ListToolsResult(tools=[]) + async with Client(server) as client: + result = await client.list_tools() + assert result.tools[0].description == "cursor=None" - async with create_session(server) as client_session: - # Use params to explicitly send params: {} for strict server compatibility - result = await client_session.list_tools(params=types.PaginatedRequestParams()) - assert result is not None + result = await client.list_tools(cursor="page2") + assert result.tools[0].description == "cursor=page2" diff --git a/tests/client/test_list_roots_callback.py b/tests/client/test_list_roots_callback.py index 0da0fff07..6a2f49f39 100644 --- a/tests/client/test_list_roots_callback.py +++ b/tests/client/test_list_roots_callback.py @@ -1,58 +1,48 @@ import pytest from pydantic import FileUrl +from mcp import Client from mcp.client.session import ClientSession -from mcp.server.fastmcp.server import Context -from mcp.server.session import ServerSession -from mcp.shared.context import RequestContext -from mcp.shared.memory import ( - create_connected_server_and_client_session as create_session, -) +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.server import Context +from mcp.shared._context import RequestContext from mcp.types import ListRootsResult, Root, TextContent @pytest.mark.anyio async def test_list_roots_callback(): - from mcp.server.fastmcp import FastMCP - - server = FastMCP("test") + server = MCPServer("test") callback_return = ListRootsResult( roots=[ - Root( - uri=FileUrl("file://users/fake/test"), - name="Test Root 1", - ), - Root( - uri=FileUrl("file://users/fake/test/2"), - name="Test Root 2", - ), + Root(uri=FileUrl("file://users/fake/test"), name="Test Root 1"), + Root(uri=FileUrl("file://users/fake/test/2"), name="Test Root 2"), ] ) async def list_roots_callback( - context: RequestContext[ClientSession, None], + context: RequestContext[ClientSession], ) -> ListRootsResult: return callback_return @server.tool("test_list_roots") - async def test_list_roots(context: Context[ServerSession, None], message: str): + async def test_list_roots(context: Context[None], message: str): roots = await context.session.list_roots() assert roots == callback_return return True # Test with list_roots callback - async with create_session(server._mcp_server, list_roots_callback=list_roots_callback) as client_session: + async with Client(server, list_roots_callback=list_roots_callback) as client: # Make a request to trigger sampling callback - result = await client_session.call_tool("test_list_roots", {"message": "test message"}) - assert result.isError is False + result = await client.call_tool("test_list_roots", {"message": "test message"}) + assert result.is_error is False assert isinstance(result.content[0], TextContent) assert result.content[0].text == "true" # Test without list_roots callback - async with create_session(server._mcp_server) as client_session: + async with Client(server) as client: # Make a request to trigger sampling callback - result = await client_session.call_tool("test_list_roots", {"message": "test message"}) - assert result.isError is True + result = await client.call_tool("test_list_roots", {"message": "test message"}) + assert result.is_error is True assert isinstance(result.content[0], TextContent) assert result.content[0].text == "Error executing tool test_list_roots: List roots not supported" diff --git a/tests/client/test_logging_callback.py b/tests/client/test_logging_callback.py index 5f5d53412..31cdeece7 100644 --- a/tests/client/test_logging_callback.py +++ b/tests/client/test_logging_callback.py @@ -1,11 +1,9 @@ -from typing import Literal +from typing import Any, Literal import pytest -import mcp.types as types -from mcp.shared.memory import ( - create_connected_server_and_client_session as create_session, -) +from mcp import Client, types +from mcp.server.mcpserver import MCPServer from mcp.shared.session import RequestResponder from mcp.types import ( LoggingMessageNotificationParams, @@ -23,9 +21,7 @@ async def __call__(self, params: LoggingMessageNotificationParams) -> None: @pytest.mark.anyio async def test_logging_callback(): - from mcp.server.fastmcp import FastMCP - - server = FastMCP("test") + server = MCPServer("test") logging_collector = LoggingCollector() # Create a simple test tool @@ -47,6 +43,23 @@ async def test_tool_with_log( ) return True + @server.tool("test_tool_with_log_extra") + async def test_tool_with_log_extra( + message: str, + level: Literal["debug", "info", "warning", "error"], + logger: str, + extra_string: str, + extra_dict: dict[str, Any], + ) -> bool: + """Send a log notification to the client with extra fields.""" + await server.get_context().log( + level=level, + message=message, + logger_name=logger, + extra={"extra_string": extra_string, "extra_dict": extra_dict}, + ) + return True + # Create a message handler to catch exceptions async def message_handler( message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, @@ -54,19 +67,19 @@ async def message_handler( if isinstance(message, Exception): # pragma: no cover raise message - async with create_session( - server._mcp_server, + async with Client( + server, logging_callback=logging_collector, message_handler=message_handler, - ) as client_session: + ) as client: # First verify our test tool works - result = await client_session.call_tool("test_tool", {}) - assert result.isError is False + result = await client.call_tool("test_tool", {}) + assert result.is_error is False assert isinstance(result.content[0], TextContent) assert result.content[0].text == "true" # Now send a log message via our tool - log_result = await client_session.call_tool( + log_result = await client.call_tool( "test_tool_with_log", { "message": "Test log message", @@ -74,10 +87,30 @@ async def message_handler( "logger": "test_logger", }, ) - assert log_result.isError is False - assert len(logging_collector.log_messages) == 1 + log_result_with_extra = await client.call_tool( + "test_tool_with_log_extra", + { + "message": "Test log message", + "level": "info", + "logger": "test_logger", + "extra_string": "example", + "extra_dict": {"a": 1, "b": 2, "c": 3}, + }, + ) + assert log_result.is_error is False + assert log_result_with_extra.is_error is False + assert len(logging_collector.log_messages) == 2 # Create meta object with related_request_id added dynamically log = logging_collector.log_messages[0] assert log.level == "info" assert log.logger == "test_logger" assert log.data == "Test log message" + + log_with_extra = logging_collector.log_messages[1] + assert log_with_extra.level == "info" + assert log_with_extra.logger == "test_logger" + assert log_with_extra.data == { + "message": "Test log message", + "extra_string": "example", + "extra_dict": {"a": 1, "b": 2, "c": 3}, + } diff --git a/tests/client/test_notification_response.py b/tests/client/test_notification_response.py index 19d537400..69c8afeb8 100644 --- a/tests/client/test_notification_response.py +++ b/tests/client/test_notification_response.py @@ -1,145 +1,203 @@ -""" -Tests for StreamableHTTP client transport with non-SDK servers. +"""Tests for StreamableHTTP client transport with non-SDK servers. These tests verify client behavior when interacting with servers that don't follow SDK conventions. """ import json -import multiprocessing -import socket -from collections.abc import Generator +import httpx import pytest -import uvicorn from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import JSONResponse, Response from starlette.routing import Route -from mcp import ClientSession, types -from mcp.client.streamable_http import streamablehttp_client +from mcp import ClientSession, MCPError, types +from mcp.client.streamable_http import streamable_http_client from mcp.shared.session import RequestResponder -from mcp.types import ClientNotification, RootsListChangedNotification -from tests.test_helpers import wait_for_server +from mcp.types import RootsListChangedNotification + +pytestmark = pytest.mark.anyio + +INIT_RESPONSE = { + "serverInfo": {"name": "test-non-sdk-server", "version": "1.0.0"}, + "protocolVersion": "2024-11-05", + "capabilities": {}, +} + +def _init_json_response(data: dict[str, object]) -> JSONResponse: + return JSONResponse({"jsonrpc": "2.0", "id": data["id"], "result": INIT_RESPONSE}) -def create_non_sdk_server_app() -> Starlette: # pragma: no cover + +def _create_non_sdk_server_app() -> Starlette: """Create a minimal server that doesn't follow SDK conventions.""" async def handle_mcp_request(request: Request) -> Response: - """Handle MCP requests with non-standard responses.""" - try: - body = await request.body() - data = json.loads(body) - - # Handle initialize request normally - if data.get("method") == "initialize": - response_data = { - "jsonrpc": "2.0", - "id": data["id"], - "result": { - "serverInfo": {"name": "test-non-sdk-server", "version": "1.0.0"}, - "protocolVersion": "2024-11-05", - "capabilities": {}, - }, - } - return JSONResponse(response_data) - - # For notifications, return 204 No Content (non-SDK behavior) - if "id" not in data: - return Response(status_code=204, headers={"Content-Type": "application/json"}) - - # Default response for other requests - return JSONResponse( - {"jsonrpc": "2.0", "id": data.get("id"), "error": {"code": -32601, "message": "Method not found"}} - ) - - except Exception as e: - return JSONResponse({"error": f"Server error: {str(e)}"}, status_code=500) - - app = Starlette( - debug=True, - routes=[ - Route("/mcp", handle_mcp_request, methods=["POST"]), - ], - ) - return app - - -def run_non_sdk_server(port: int) -> None: # pragma: no cover - """Run the non-SDK server in a separate process.""" - app = create_non_sdk_server_app() - config = uvicorn.Config( - app=app, - host="127.0.0.1", - port=port, - log_level="error", # Reduce noise in tests - ) - server = uvicorn.Server(config=config) - server.run() - - -@pytest.fixture -def non_sdk_server_port() -> int: - """Get an available port for the test server.""" - with socket.socket() as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] - - -@pytest.fixture -def non_sdk_server(non_sdk_server_port: int) -> Generator[None, None, None]: - """Start a non-SDK server for testing.""" - proc = multiprocessing.Process(target=run_non_sdk_server, kwargs={"port": non_sdk_server_port}, daemon=True) - proc.start() - - # Wait for server to be ready - try: # pragma: no cover - wait_for_server(non_sdk_server_port, timeout=10.0) - except TimeoutError: # pragma: no cover - proc.kill() - proc.join(timeout=2) - pytest.fail("Server failed to start within 10 seconds") - - yield - - proc.kill() - proc.join(timeout=2) - - -@pytest.mark.anyio -async def test_non_compliant_notification_response(non_sdk_server: None, non_sdk_server_port: int) -> None: - """ - This test verifies that the client ignores unexpected responses to notifications: the spec states they should - either be 202 + no response body, or 4xx + optional error body + body = await request.body() + data = json.loads(body) + + if data.get("method") == "initialize": + return _init_json_response(data) + + # For notifications, return 204 No Content (non-SDK behavior) + if "id" not in data: + return Response(status_code=204, headers={"Content-Type": "application/json"}) + + return JSONResponse( # pragma: no cover + {"jsonrpc": "2.0", "id": data.get("id"), "error": {"code": -32601, "message": "Method not found"}} + ) + + return Starlette(debug=True, routes=[Route("/mcp", handle_mcp_request, methods=["POST"])]) + + +def _create_unexpected_content_type_app() -> Starlette: + """Create a server that returns an unexpected content type for requests.""" + + async def handle_mcp_request(request: Request) -> Response: + body = await request.body() + data = json.loads(body) + + if data.get("method") == "initialize": + return _init_json_response(data) + + if "id" not in data: + return Response(status_code=202) + + # Return text/plain for all other requests — an unexpected content type. + return Response(content="this is plain text, not json or sse", status_code=200, media_type="text/plain") + + return Starlette(debug=True, routes=[Route("/mcp", handle_mcp_request, methods=["POST"])]) + + +async def test_non_compliant_notification_response() -> None: + """Verify the client ignores unexpected responses to notifications. + + The spec states notifications should get either 202 + no response body, or 4xx + optional error body (https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#sending-messages-to-the-server), but some servers wrongly return other 2xx codes (e.g. 204). For now we simply ignore unexpected responses (aligning behaviour w/ the TS SDK). """ - server_url = f"http://127.0.0.1:{non_sdk_server_port}/mcp" returned_exception = None async def message_handler( # pragma: no cover message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, - ): + ) -> None: nonlocal returned_exception if isinstance(message, Exception): returned_exception = message - async with streamablehttp_client(server_url) as (read_stream, write_stream, _): - async with ClientSession( - read_stream, - write_stream, - message_handler=message_handler, - ) as session: - # Initialize should work normally - await session.initialize() + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_create_non_sdk_server_app())) as client: + async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session: + await session.initialize() - # The test server returns a 204 instead of the expected 202 - await session.send_notification( - ClientNotification(RootsListChangedNotification(method="notifications/roots/list_changed")) - ) + # The test server returns a 204 instead of the expected 202 + await session.send_notification(RootsListChangedNotification(method="notifications/roots/list_changed")) if returned_exception: # pragma: no cover pytest.fail(f"Server encountered an exception: {returned_exception}") + + +async def test_unexpected_content_type_sends_jsonrpc_error() -> None: + """Verify unexpected content types unblock the pending request with an MCPError. + + When a server returns a content type that is neither application/json nor text/event-stream, + the client should send a JSONRPCError so the pending request resolves immediately + instead of hanging until timeout. + """ + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_create_unexpected_content_type_app())) as client: + async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: # pragma: no branch + await session.initialize() + + with pytest.raises(MCPError, match="Unexpected content type: text/plain"): # pragma: no branch + await session.list_tools() + + +def _create_http_error_app(error_status: int, *, error_on_notifications: bool = False) -> Starlette: + """Create a server that returns an HTTP error for non-init requests.""" + + async def handle_mcp_request(request: Request) -> Response: + body = await request.body() + data = json.loads(body) + + if data.get("method") == "initialize": + return _init_json_response(data) + + if "id" not in data: + if error_on_notifications: + return Response(status_code=error_status) + return Response(status_code=202) + + return Response(status_code=error_status) + + return Starlette(debug=True, routes=[Route("/mcp", handle_mcp_request, methods=["POST"])]) + + +async def test_http_error_status_sends_jsonrpc_error() -> None: + """Verify HTTP 5xx errors unblock the pending request with an MCPError. + + When a server returns a non-2xx status code (e.g. 500), the client should + send a JSONRPCError so the pending request resolves immediately instead of + raising an unhandled httpx.HTTPStatusError that causes the caller to hang. + """ + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_create_http_error_app(500))) as client: + async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: # pragma: no branch + await session.initialize() + + with pytest.raises(MCPError, match="Server returned an error response"): # pragma: no branch + await session.list_tools() + + +async def test_http_error_on_notification_does_not_hang() -> None: + """Verify HTTP errors on notifications are silently ignored. + + When a notification gets an HTTP error, there is no pending request to + unblock, so the client should just return without sending a JSONRPCError. + """ + app = _create_http_error_app(500, error_on_notifications=True) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client: + async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: # pragma: no branch + await session.initialize() + + # Should not raise or hang — the error is silently ignored for notifications + await session.send_notification(RootsListChangedNotification(method="notifications/roots/list_changed")) + + +def _create_invalid_json_response_app() -> Starlette: + """Create a server that returns invalid JSON for requests.""" + + async def handle_mcp_request(request: Request) -> Response: + body = await request.body() + data = json.loads(body) + + if data.get("method") == "initialize": + return _init_json_response(data) + + if "id" not in data: + return Response(status_code=202) + + # Return application/json content type but with invalid JSON body. + return Response(content="not valid json{{{", status_code=200, media_type="application/json") + + return Starlette(debug=True, routes=[Route("/mcp", handle_mcp_request, methods=["POST"])]) + + +async def test_invalid_json_response_sends_jsonrpc_error() -> None: + """Verify invalid JSON responses unblock the pending request with an MCPError. + + When a server returns application/json with an unparseable body, the client + should send a JSONRPCError so the pending request resolves immediately + instead of hanging until timeout. + """ + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=_create_invalid_json_response_app())) as client: + async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: # pragma: no branch + await session.initialize() + + with pytest.raises(MCPError, match="Failed to parse JSON response"): # pragma: no branch + await session.list_tools() diff --git a/tests/client/test_output_schema_validation.py b/tests/client/test_output_schema_validation.py index e4a06b7f8..d78197b5c 100644 --- a/tests/client/test_output_schema_validation.py +++ b/tests/client/test_output_schema_validation.py @@ -1,217 +1,165 @@ import logging -from contextlib import contextmanager from typing import Any -from unittest.mock import patch import pytest -from mcp.server.lowlevel import Server -from mcp.shared.memory import ( - create_connected_server_and_client_session as client_session, +from mcp import Client +from mcp.server import Server, ServerRequestContext +from mcp.types import ( + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, ) -from mcp.types import Tool - - -@contextmanager -def bypass_server_output_validation(): - """ - Context manager that bypasses server-side output validation. - This simulates a malicious or non-compliant server that doesn't validate - its outputs, allowing us to test client-side validation. - """ - import jsonschema - - # Save the original validate function - original_validate = jsonschema.validate - - # Create a mock that tracks which module is calling it - def selective_mock(instance: Any = None, schema: Any = None, *args: Any, **kwargs: Any) -> None: - import inspect - - # Check the call stack to see where this is being called from - for frame_info in inspect.stack(): - # If called from the server module, skip validation - # TODO: fix this as it's a rather gross workaround and will eventually break - # Normalize path separators for cross-platform compatibility - normalized_path = frame_info.filename.replace("\\", "/") - if "mcp/server/lowlevel/server.py" in normalized_path: - return None - # Otherwise, use the real validation (for client-side) - return original_validate(instance=instance, schema=schema, *args, **kwargs) - - with patch("jsonschema.validate", selective_mock): - yield - - -class TestClientOutputSchemaValidation: - """Test client-side validation of structured output from tools""" - - @pytest.mark.anyio - async def test_tool_structured_output_client_side_validation_basemodel(self): - """Test that client validates structured content against schema for BaseModel outputs""" - # Create a malicious low-level server that returns invalid structured content - server = Server("test-server") - - # Define the expected schema for our tool - output_schema = { - "type": "object", - "properties": {"name": {"type": "string", "title": "Name"}, "age": {"type": "integer", "title": "Age"}}, - "required": ["name", "age"], - "title": "UserOutput", - } - - @server.list_tools() - async def list_tools(): - return [ - Tool( - name="get_user", - description="Get user data", - inputSchema={"type": "object"}, - outputSchema=output_schema, - ) - ] - - @server.call_tool() - async def call_tool(name: str, arguments: dict[str, Any]): - # Return invalid structured content - age is string instead of integer - # The low-level server will wrap this in CallToolResult - return {"name": "John", "age": "invalid"} # Invalid: age should be int - - # Test that client validates the structured content - with bypass_server_output_validation(): - async with client_session(server) as client: - # The client validates structured content and should raise an error - with pytest.raises(RuntimeError) as exc_info: - await client.call_tool("get_user", {}) - # Verify it's a validation error - assert "Invalid structured content returned by tool get_user" in str(exc_info.value) - - @pytest.mark.anyio - async def test_tool_structured_output_client_side_validation_primitive(self): - """Test that client validates structured content for primitive outputs""" - server = Server("test-server") - - # Primitive types are wrapped in {"result": value} - output_schema = { - "type": "object", - "properties": {"result": {"type": "integer", "title": "Result"}}, - "required": ["result"], - "title": "calculate_Output", - } - - @server.list_tools() - async def list_tools(): - return [ - Tool( - name="calculate", - description="Calculate something", - inputSchema={"type": "object"}, - outputSchema=output_schema, - ) - ] - - @server.call_tool() - async def call_tool(name: str, arguments: dict[str, Any]): - # Return invalid structured content - result is string instead of integer - return {"result": "not_a_number"} # Invalid: should be int - - with bypass_server_output_validation(): - async with client_session(server) as client: - # The client validates structured content and should raise an error - with pytest.raises(RuntimeError) as exc_info: - await client.call_tool("calculate", {}) - assert "Invalid structured content returned by tool calculate" in str(exc_info.value) - - @pytest.mark.anyio - async def test_tool_structured_output_client_side_validation_dict_typed(self): - """Test that client validates dict[str, T] structured content""" - server = Server("test-server") - - # dict[str, int] schema - output_schema = {"type": "object", "additionalProperties": {"type": "integer"}, "title": "get_scores_Output"} - - @server.list_tools() - async def list_tools(): - return [ - Tool( - name="get_scores", - description="Get scores", - inputSchema={"type": "object"}, - outputSchema=output_schema, - ) - ] - - @server.call_tool() - async def call_tool(name: str, arguments: dict[str, Any]): - # Return invalid structured content - values should be integers - return {"alice": "100", "bob": "85"} # Invalid: values should be int - - with bypass_server_output_validation(): - async with client_session(server) as client: - # The client validates structured content and should raise an error - with pytest.raises(RuntimeError) as exc_info: - await client.call_tool("get_scores", {}) - assert "Invalid structured content returned by tool get_scores" in str(exc_info.value) - - @pytest.mark.anyio - async def test_tool_structured_output_client_side_validation_missing_required(self): - """Test that client validates missing required fields""" - server = Server("test-server") - - output_schema = { - "type": "object", - "properties": {"name": {"type": "string"}, "age": {"type": "integer"}, "email": {"type": "string"}}, - "required": ["name", "age", "email"], # All fields required - "title": "PersonOutput", - } - - @server.list_tools() - async def list_tools(): - return [ - Tool( - name="get_person", - description="Get person data", - inputSchema={"type": "object"}, - outputSchema=output_schema, - ) - ] - - @server.call_tool() - async def call_tool(name: str, arguments: dict[str, Any]): - # Return structured content missing required field 'email' - return {"name": "John", "age": 30} # Missing required 'email' - - with bypass_server_output_validation(): - async with client_session(server) as client: - # The client validates structured content and should raise an error - with pytest.raises(RuntimeError) as exc_info: - await client.call_tool("get_person", {}) - assert "Invalid structured content returned by tool get_person" in str(exc_info.value) - - @pytest.mark.anyio - async def test_tool_not_listed_warning(self, caplog: pytest.LogCaptureFixture): - """Test that client logs warning when tool is not in list_tools but has outputSchema""" - server = Server("test-server") - - @server.list_tools() - async def list_tools() -> list[Tool]: - # Return empty list - tool is not listed - return [] - - @server.call_tool() - async def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]: - # Server still responds to the tool call with structured content - return {"result": 42} - - # Set logging level to capture warnings - caplog.set_level(logging.WARNING) - - with bypass_server_output_validation(): - async with client_session(server) as client: - # Call a tool that wasn't listed - result = await client.call_tool("mystery_tool", {}) - assert result.structuredContent == {"result": 42} - assert result.isError is False - - # Check that warning was logged - assert "Tool mystery_tool not listed" in caplog.text + + +def _make_server( + tools: list[Tool], + structured_content: dict[str, Any], +) -> Server: + """Create a low-level server that returns the given structured_content for any tool call.""" + + async def on_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=tools) + + async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + return CallToolResult( + content=[TextContent(type="text", text="result")], + structured_content=structured_content, + ) + + return Server("test-server", on_list_tools=on_list_tools, on_call_tool=on_call_tool) + + +@pytest.mark.anyio +async def test_tool_structured_output_client_side_validation_basemodel(): + """Test that client validates structured content against schema for BaseModel outputs""" + output_schema = { + "type": "object", + "properties": {"name": {"type": "string", "title": "Name"}, "age": {"type": "integer", "title": "Age"}}, + "required": ["name", "age"], + "title": "UserOutput", + } + + server = _make_server( + tools=[ + Tool( + name="get_user", + description="Get user data", + input_schema={"type": "object"}, + output_schema=output_schema, + ) + ], + structured_content={"name": "John", "age": "invalid"}, # Invalid: age should be int + ) + + async with Client(server) as client: + with pytest.raises(RuntimeError) as exc_info: + await client.call_tool("get_user", {}) + assert "Invalid structured content returned by tool get_user" in str(exc_info.value) + + +@pytest.mark.anyio +async def test_tool_structured_output_client_side_validation_primitive(): + """Test that client validates structured content for primitive outputs""" + output_schema = { + "type": "object", + "properties": {"result": {"type": "integer", "title": "Result"}}, + "required": ["result"], + "title": "calculate_Output", + } + + server = _make_server( + tools=[ + Tool( + name="calculate", + description="Calculate something", + input_schema={"type": "object"}, + output_schema=output_schema, + ) + ], + structured_content={"result": "not_a_number"}, # Invalid: should be int + ) + + async with Client(server) as client: + with pytest.raises(RuntimeError) as exc_info: + await client.call_tool("calculate", {}) + assert "Invalid structured content returned by tool calculate" in str(exc_info.value) + + +@pytest.mark.anyio +async def test_tool_structured_output_client_side_validation_dict_typed(): + """Test that client validates dict[str, T] structured content""" + output_schema = {"type": "object", "additionalProperties": {"type": "integer"}, "title": "get_scores_Output"} + + server = _make_server( + tools=[ + Tool( + name="get_scores", + description="Get scores", + input_schema={"type": "object"}, + output_schema=output_schema, + ) + ], + structured_content={"alice": "100", "bob": "85"}, # Invalid: values should be int + ) + + async with Client(server) as client: + with pytest.raises(RuntimeError) as exc_info: + await client.call_tool("get_scores", {}) + assert "Invalid structured content returned by tool get_scores" in str(exc_info.value) + + +@pytest.mark.anyio +async def test_tool_structured_output_client_side_validation_missing_required(): + """Test that client validates missing required fields""" + output_schema = { + "type": "object", + "properties": {"name": {"type": "string"}, "age": {"type": "integer"}, "email": {"type": "string"}}, + "required": ["name", "age", "email"], + "title": "PersonOutput", + } + + server = _make_server( + tools=[ + Tool( + name="get_person", + description="Get person data", + input_schema={"type": "object"}, + output_schema=output_schema, + ) + ], + structured_content={"name": "John", "age": 30}, # Missing required 'email' + ) + + async with Client(server) as client: + with pytest.raises(RuntimeError) as exc_info: + await client.call_tool("get_person", {}) + assert "Invalid structured content returned by tool get_person" in str(exc_info.value) + + +@pytest.mark.anyio +async def test_tool_not_listed_warning(caplog: pytest.LogCaptureFixture): + """Test that client logs warning when tool is not in list_tools but has output_schema""" + + async def on_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[]) + + async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + return CallToolResult( + content=[TextContent(type="text", text="result")], + structured_content={"result": 42}, + ) + + server = Server("test-server", on_list_tools=on_list_tools, on_call_tool=on_call_tool) + + caplog.set_level(logging.WARNING) + + async with Client(server) as client: + result = await client.call_tool("mystery_tool", {}) + assert result.structured_content == {"result": 42} + assert result.is_error is False + + assert "Tool mystery_tool not listed" in caplog.text diff --git a/tests/client/test_resource_cleanup.py b/tests/client/test_resource_cleanup.py index cc6c5059f..c7bf8fafa 100644 --- a/tests/client/test_resource_cleanup.py +++ b/tests/client/test_resource_cleanup.py @@ -3,6 +3,7 @@ import anyio import pytest +from pydantic import TypeAdapter from mcp.shared.message import SessionMessage from mcp.shared.session import BaseSession, RequestId, SendResultT @@ -11,8 +12,7 @@ @pytest.mark.anyio async def test_send_request_stream_cleanup(): - """ - Test that send_request properly cleans up streams when an exception occurs. + """Test that send_request properly cleans up streams when an exception occurs. This test mocks out most of the session functionality to focus on stream cleanup. """ @@ -24,20 +24,23 @@ async def _send_response( ) -> None: # pragma: no cover pass + @property + def _receive_request_adapter(self) -> TypeAdapter[Any]: + return TypeAdapter(object) # pragma: no cover + + @property + def _receive_notification_adapter(self) -> TypeAdapter[Any]: + return TypeAdapter(object) # pragma: no cover + # Create streams write_stream_send, write_stream_receive = anyio.create_memory_object_stream[SessionMessage](1) read_stream_send, read_stream_receive = anyio.create_memory_object_stream[SessionMessage](1) # Create the session - session = TestSession( - read_stream_receive, - write_stream_send, - object, # Request type doesn't matter for this test - object, # Notification type doesn't matter for this test - ) + session = TestSession(read_stream_receive, write_stream_send) # Create a test request - request = ClientRequest(PingRequest()) + request = PingRequest() # Patch the _write_stream.send method to raise an exception async def mock_send(*args: Any, **kwargs: Any): diff --git a/tests/client/test_sampling_callback.py b/tests/client/test_sampling_callback.py index 733364a76..3357bc921 100644 --- a/tests/client/test_sampling_callback.py +++ b/tests/client/test_sampling_callback.py @@ -1,10 +1,9 @@ import pytest +from mcp import Client from mcp.client.session import ClientSession -from mcp.shared.context import RequestContext -from mcp.shared.memory import ( - create_connected_server_and_client_session as create_session, -) +from mcp.server.mcpserver import MCPServer +from mcp.shared._context import RequestContext from mcp.types import ( CreateMessageRequestParams, CreateMessageResult, @@ -17,19 +16,17 @@ @pytest.mark.anyio async def test_sampling_callback(): - from mcp.server.fastmcp import FastMCP - - server = FastMCP("test") + server = MCPServer("test") callback_return = CreateMessageResult( role="assistant", content=TextContent(type="text", text="This is a response from the sampling callback"), model="test-model", - stopReason="endTurn", + stop_reason="endTurn", ) async def sampling_callback( - context: RequestContext[ClientSession, None], + context: RequestContext[ClientSession], params: CreateMessageRequestParams, ) -> CreateMessageResult: return callback_return @@ -44,18 +41,18 @@ async def test_sampling_tool(message: str): return True # Test with sampling callback - async with create_session(server._mcp_server, sampling_callback=sampling_callback) as client_session: + async with Client(server, sampling_callback=sampling_callback) as client: # Make a request to trigger sampling callback - result = await client_session.call_tool("test_sampling", {"message": "Test message for sampling"}) - assert result.isError is False + result = await client.call_tool("test_sampling", {"message": "Test message for sampling"}) + assert result.is_error is False assert isinstance(result.content[0], TextContent) assert result.content[0].text == "true" # Test without sampling callback - async with create_session(server._mcp_server) as client_session: + async with Client(server) as client: # Make a request to trigger sampling callback - result = await client_session.call_tool("test_sampling", {"message": "Test message for sampling"}) - assert result.isError is True + result = await client.call_tool("test_sampling", {"message": "Test message for sampling"}) + assert result.is_error is True assert isinstance(result.content[0], TextContent) assert result.content[0].text == "Error executing tool test_sampling: Sampling not supported" @@ -63,20 +60,18 @@ async def test_sampling_tool(message: str): @pytest.mark.anyio async def test_create_message_backwards_compat_single_content(): """Test backwards compatibility: create_message without tools returns single content.""" - from mcp.server.fastmcp import FastMCP - - server = FastMCP("test") + server = MCPServer("test") # Callback returns single content (text) callback_return = CreateMessageResult( role="assistant", content=TextContent(type="text", text="Hello from LLM"), model="test-model", - stopReason="endTurn", + stop_reason="endTurn", ) async def sampling_callback( - context: RequestContext[ClientSession, None], + context: RequestContext[ClientSession], params: CreateMessageRequestParams, ) -> CreateMessageResult: return callback_return @@ -97,9 +92,9 @@ async def test_tool(message: str): assert not hasattr(result, "content_as_list") or not callable(getattr(result, "content_as_list", None)) return True - async with create_session(server._mcp_server, sampling_callback=sampling_callback) as client_session: - result = await client_session.call_tool("test_backwards_compat", {"message": "Test"}) - assert result.isError is False + async with Client(server, sampling_callback=sampling_callback) as client: + result = await client.call_tool("test_backwards_compat", {"message": "Test"}) + assert result.is_error is False assert isinstance(result.content[0], TextContent) assert result.content[0].text == "true" @@ -112,7 +107,7 @@ async def test_create_message_result_with_tools_type(): role="assistant", content=ToolUseContent(type="tool_use", id="call_123", name="get_weather", input={"city": "SF"}), model="test-model", - stopReason="toolUse", + stop_reason="toolUse", ) # CreateMessageResultWithTools should have content_as_list @@ -128,7 +123,7 @@ async def test_create_message_result_with_tools_type(): ToolUseContent(type="tool_use", id="call_456", name="get_weather", input={"city": "NYC"}), ], model="test-model", - stopReason="toolUse", + stop_reason="toolUse", ) content_list_array = result_array.content_as_list assert len(content_list_array) == 2 diff --git a/tests/client/test_scope_bug_1630.py b/tests/client/test_scope_bug_1630.py index 7884718c1..fafa51007 100644 --- a/tests/client/test_scope_bug_1630.py +++ b/tests/client/test_scope_bug_1630.py @@ -1,5 +1,4 @@ -""" -Regression test for issue #1630: OAuth2 scope incorrectly set to resource_metadata URL. +"""Regression test for issue #1630: OAuth2 scope incorrectly set to resource_metadata URL. This test verifies that when a 401 response contains both resource_metadata and scope in the WWW-Authenticate header, the actual scope is used (not the resource_metadata URL). @@ -37,8 +36,7 @@ async def set_client_info(self, client_info: OAuthClientInformationFull) -> None @pytest.mark.anyio async def test_401_uses_www_auth_scope_not_resource_metadata_url(): - """ - Regression test for #1630: Ensure scope is extracted from WWW-Authenticate header, + """Regression test for #1630: Ensure scope is extracted from WWW-Authenticate header, not the resource_metadata URL. When a 401 response contains: diff --git a/tests/client/test_session.py b/tests/client/test_session.py index 8d0ef68a9..d6d13e273 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -1,30 +1,29 @@ -from typing import Any +from __future__ import annotations import anyio import pytest -import mcp.types as types +from mcp import types from mcp.client.session import DEFAULT_CLIENT_INFO, ClientSession -from mcp.shared.context import RequestContext +from mcp.shared._context import RequestContext from mcp.shared.message import SessionMessage from mcp.shared.session import RequestResponder from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS from mcp.types import ( LATEST_PROTOCOL_VERSION, CallToolResult, - ClientNotification, - ClientRequest, Implementation, InitializedNotification, InitializeRequest, InitializeResult, - JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse, + RequestParamsMeta, ServerCapabilities, - ServerResult, TextContent, + client_notification_adapter, + client_request_adapter, ) @@ -41,43 +40,39 @@ async def mock_server(): session_message = await client_to_server_receive.receive() jsonrpc_request = session_message.message - assert isinstance(jsonrpc_request.root, JSONRPCRequest) - request = ClientRequest.model_validate( + assert isinstance(jsonrpc_request, JSONRPCRequest) + request = client_request_adapter.validate_python( jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) ) - assert isinstance(request.root, InitializeRequest) - - result = ServerResult( - InitializeResult( - protocolVersion=LATEST_PROTOCOL_VERSION, - capabilities=ServerCapabilities( - logging=None, - resources=None, - tools=None, - experimental=None, - prompts=None, - ), - serverInfo=Implementation(name="mock-server", version="0.1.0"), - instructions="The server instructions.", - ) + assert isinstance(request, InitializeRequest) + + result = InitializeResult( + protocol_version=LATEST_PROTOCOL_VERSION, + capabilities=ServerCapabilities( + logging=None, + resources=None, + tools=None, + experimental=None, + prompts=None, + ), + server_info=Implementation(name="mock-server", version="0.1.0"), + instructions="The server instructions.", ) async with server_to_client_send: await server_to_client_send.send( SessionMessage( - JSONRPCMessage( - JSONRPCResponse( - jsonrpc="2.0", - id=jsonrpc_request.root.id, - result=result.model_dump(by_alias=True, mode="json", exclude_none=True), - ) + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), ) ) ) session_notification = await client_to_server_receive.receive() jsonrpc_notification = session_notification.message - assert isinstance(jsonrpc_notification.root, JSONRPCNotification) - initialized_notification = ClientNotification.model_validate( + assert isinstance(jsonrpc_notification, JSONRPCNotification) + initialized_notification = client_notification_adapter.validate_python( jsonrpc_notification.model_dump(by_alias=True, mode="json", exclude_none=True) ) @@ -105,14 +100,14 @@ async def message_handler( # pragma: no cover # Assert the result assert isinstance(result, InitializeResult) - assert result.protocolVersion == LATEST_PROTOCOL_VERSION + assert result.protocol_version == LATEST_PROTOCOL_VERSION assert isinstance(result.capabilities, ServerCapabilities) - assert result.serverInfo == Implementation(name="mock-server", version="0.1.0") + assert result.server_info == Implementation(name="mock-server", version="0.1.0") assert result.instructions == "The server instructions." # Check that the client sent the initialized notification assert initialized_notification - assert isinstance(initialized_notification.root, InitializedNotification) + assert isinstance(initialized_notification, InitializedNotification) @pytest.mark.anyio @@ -128,30 +123,26 @@ async def mock_server(): session_message = await client_to_server_receive.receive() jsonrpc_request = session_message.message - assert isinstance(jsonrpc_request.root, JSONRPCRequest) - request = ClientRequest.model_validate( + assert isinstance(jsonrpc_request, JSONRPCRequest) + request = client_request_adapter.validate_python( jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) ) - assert isinstance(request.root, InitializeRequest) - received_client_info = request.root.params.clientInfo - - result = ServerResult( - InitializeResult( - protocolVersion=LATEST_PROTOCOL_VERSION, - capabilities=ServerCapabilities(), - serverInfo=Implementation(name="mock-server", version="0.1.0"), - ) + assert isinstance(request, InitializeRequest) + received_client_info = request.params.client_info + + result = InitializeResult( + protocol_version=LATEST_PROTOCOL_VERSION, + capabilities=ServerCapabilities(), + server_info=Implementation(name="mock-server", version="0.1.0"), ) async with server_to_client_send: await server_to_client_send.send( SessionMessage( - JSONRPCMessage( - JSONRPCResponse( - jsonrpc="2.0", - id=jsonrpc_request.root.id, - result=result.model_dump(by_alias=True, mode="json", exclude_none=True), - ) + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), ) ) ) @@ -189,30 +180,26 @@ async def mock_server(): session_message = await client_to_server_receive.receive() jsonrpc_request = session_message.message - assert isinstance(jsonrpc_request.root, JSONRPCRequest) - request = ClientRequest.model_validate( + assert isinstance(jsonrpc_request, JSONRPCRequest) + request = client_request_adapter.validate_python( jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) ) - assert isinstance(request.root, InitializeRequest) - received_client_info = request.root.params.clientInfo - - result = ServerResult( - InitializeResult( - protocolVersion=LATEST_PROTOCOL_VERSION, - capabilities=ServerCapabilities(), - serverInfo=Implementation(name="mock-server", version="0.1.0"), - ) + assert isinstance(request, InitializeRequest) + received_client_info = request.params.client_info + + result = InitializeResult( + protocol_version=LATEST_PROTOCOL_VERSION, + capabilities=ServerCapabilities(), + server_info=Implementation(name="mock-server", version="0.1.0"), ) async with server_to_client_send: await server_to_client_send.send( SessionMessage( - JSONRPCMessage( - JSONRPCResponse( - jsonrpc="2.0", - id=jsonrpc_request.root.id, - result=result.model_dump(by_alias=True, mode="json", exclude_none=True), - ) + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), ) ) ) @@ -220,10 +207,7 @@ async def mock_server(): await client_to_server_receive.receive() async with ( - ClientSession( - server_to_client_receive, - client_to_server_send, - ) as session, + ClientSession(server_to_client_receive, client_to_server_send) as session, anyio.create_task_group() as tg, client_to_server_send, client_to_server_receive, @@ -247,33 +231,29 @@ async def test_client_session_version_negotiation_success(): async def mock_server(): session_message = await client_to_server_receive.receive() jsonrpc_request = session_message.message - assert isinstance(jsonrpc_request.root, JSONRPCRequest) - request = ClientRequest.model_validate( + assert isinstance(jsonrpc_request, JSONRPCRequest) + request = client_request_adapter.validate_python( jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) ) - assert isinstance(request.root, InitializeRequest) + assert isinstance(request, InitializeRequest) # Verify client sent the latest protocol version - assert request.root.params.protocolVersion == LATEST_PROTOCOL_VERSION + assert request.params.protocol_version == LATEST_PROTOCOL_VERSION # Server responds with a supported older version - result = ServerResult( - InitializeResult( - protocolVersion="2024-11-05", - capabilities=ServerCapabilities(), - serverInfo=Implementation(name="mock-server", version="0.1.0"), - ) + result = InitializeResult( + protocol_version="2024-11-05", + capabilities=ServerCapabilities(), + server_info=Implementation(name="mock-server", version="0.1.0"), ) async with server_to_client_send: await server_to_client_send.send( SessionMessage( - JSONRPCMessage( - JSONRPCResponse( - jsonrpc="2.0", - id=jsonrpc_request.root.id, - result=result.model_dump(by_alias=True, mode="json", exclude_none=True), - ) + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), ) ) ) @@ -281,10 +261,7 @@ async def mock_server(): await client_to_server_receive.receive() async with ( - ClientSession( - server_to_client_receive, - client_to_server_send, - ) as session, + ClientSession(server_to_client_receive, client_to_server_send) as session, anyio.create_task_group() as tg, client_to_server_send, client_to_server_receive, @@ -296,8 +273,8 @@ async def mock_server(): # Assert the result with negotiated version assert isinstance(result, InitializeResult) - assert result.protocolVersion == "2024-11-05" - assert result.protocolVersion in SUPPORTED_PROTOCOL_VERSIONS + assert result.protocol_version == "2024-11-05" + assert result.protocol_version in SUPPORTED_PROTOCOL_VERSIONS @pytest.mark.anyio @@ -309,39 +286,32 @@ async def test_client_session_version_negotiation_failure(): async def mock_server(): session_message = await client_to_server_receive.receive() jsonrpc_request = session_message.message - assert isinstance(jsonrpc_request.root, JSONRPCRequest) - request = ClientRequest.model_validate( + assert isinstance(jsonrpc_request, JSONRPCRequest) + request = client_request_adapter.validate_python( jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) ) - assert isinstance(request.root, InitializeRequest) + assert isinstance(request, InitializeRequest) # Server responds with an unsupported version - result = ServerResult( - InitializeResult( - protocolVersion="2020-01-01", # Unsupported old version - capabilities=ServerCapabilities(), - serverInfo=Implementation(name="mock-server", version="0.1.0"), - ) + result = InitializeResult( + protocol_version="2020-01-01", # Unsupported old version + capabilities=ServerCapabilities(), + server_info=Implementation(name="mock-server", version="0.1.0"), ) async with server_to_client_send: await server_to_client_send.send( SessionMessage( - JSONRPCMessage( - JSONRPCResponse( - jsonrpc="2.0", - id=jsonrpc_request.root.id, - result=result.model_dump(by_alias=True, mode="json", exclude_none=True), - ) + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), ) ) ) async with ( - ClientSession( - server_to_client_receive, - client_to_server_send, - ) as session, + ClientSession(server_to_client_receive, client_to_server_send) as session, anyio.create_task_group() as tg, client_to_server_send, client_to_server_receive, @@ -368,30 +338,26 @@ async def mock_server(): session_message = await client_to_server_receive.receive() jsonrpc_request = session_message.message - assert isinstance(jsonrpc_request.root, JSONRPCRequest) - request = ClientRequest.model_validate( + assert isinstance(jsonrpc_request, JSONRPCRequest) + request = client_request_adapter.validate_python( jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) ) - assert isinstance(request.root, InitializeRequest) - received_capabilities = request.root.params.capabilities - - result = ServerResult( - InitializeResult( - protocolVersion=LATEST_PROTOCOL_VERSION, - capabilities=ServerCapabilities(), - serverInfo=Implementation(name="mock-server", version="0.1.0"), - ) + assert isinstance(request, InitializeRequest) + received_capabilities = request.params.capabilities + + result = InitializeResult( + protocol_version=LATEST_PROTOCOL_VERSION, + capabilities=ServerCapabilities(), + server_info=Implementation(name="mock-server", version="0.1.0"), ) async with server_to_client_send: await server_to_client_send.send( SessionMessage( - JSONRPCMessage( - JSONRPCResponse( - jsonrpc="2.0", - id=jsonrpc_request.root.id, - result=result.model_dump(by_alias=True, mode="json", exclude_none=True), - ) + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), ) ) ) @@ -399,10 +365,7 @@ async def mock_server(): await client_to_server_receive.receive() async with ( - ClientSession( - server_to_client_receive, - client_to_server_send, - ) as session, + ClientSession(server_to_client_receive, client_to_server_send) as session, anyio.create_task_group() as tg, client_to_server_send, client_to_server_receive, @@ -427,7 +390,7 @@ async def test_client_capabilities_with_custom_callbacks(): received_capabilities = None async def custom_sampling_callback( # pragma: no cover - context: RequestContext["ClientSession", Any], + context: RequestContext[ClientSession], params: types.CreateMessageRequestParams, ) -> types.CreateMessageResult | types.ErrorData: return types.CreateMessageResult( @@ -437,7 +400,7 @@ async def custom_sampling_callback( # pragma: no cover ) async def custom_list_roots_callback( # pragma: no cover - context: RequestContext["ClientSession", Any], + context: RequestContext[ClientSession], ) -> types.ListRootsResult | types.ErrorData: return types.ListRootsResult(roots=[]) @@ -446,30 +409,26 @@ async def mock_server(): session_message = await client_to_server_receive.receive() jsonrpc_request = session_message.message - assert isinstance(jsonrpc_request.root, JSONRPCRequest) - request = ClientRequest.model_validate( + assert isinstance(jsonrpc_request, JSONRPCRequest) + request = client_request_adapter.validate_python( jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) ) - assert isinstance(request.root, InitializeRequest) - received_capabilities = request.root.params.capabilities - - result = ServerResult( - InitializeResult( - protocolVersion=LATEST_PROTOCOL_VERSION, - capabilities=ServerCapabilities(), - serverInfo=Implementation(name="mock-server", version="0.1.0"), - ) + assert isinstance(request, InitializeRequest) + received_capabilities = request.params.capabilities + + result = InitializeResult( + protocol_version=LATEST_PROTOCOL_VERSION, + capabilities=ServerCapabilities(), + server_info=Implementation(name="mock-server", version="0.1.0"), ) async with server_to_client_send: await server_to_client_send.send( SessionMessage( - JSONRPCMessage( - JSONRPCResponse( - jsonrpc="2.0", - id=jsonrpc_request.root.id, - result=result.model_dump(by_alias=True, mode="json", exclude_none=True), - ) + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), ) ) ) @@ -497,11 +456,87 @@ async def mock_server(): # Custom sampling callback provided assert received_capabilities.sampling is not None assert isinstance(received_capabilities.sampling, types.SamplingCapability) + # Default sampling capabilities (no tools) + assert received_capabilities.sampling.tools is None # Custom list_roots callback provided assert received_capabilities.roots is not None assert isinstance(received_capabilities.roots, types.RootsCapability) # Should be True for custom callback - assert received_capabilities.roots.listChanged is True + assert received_capabilities.roots.list_changed is True + + +@pytest.mark.anyio +async def test_client_capabilities_with_sampling_tools(): + """Test that sampling capabilities with tools are properly advertised""" + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1) + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) + + received_capabilities = None + + async def custom_sampling_callback( # pragma: no cover + context: RequestContext[ClientSession], + params: types.CreateMessageRequestParams, + ) -> types.CreateMessageResult | types.ErrorData: + return types.CreateMessageResult( + role="assistant", + content=types.TextContent(type="text", text="test"), + model="test-model", + ) + + async def mock_server(): + nonlocal received_capabilities + + session_message = await client_to_server_receive.receive() + jsonrpc_request = session_message.message + assert isinstance(jsonrpc_request, JSONRPCRequest) + request = client_request_adapter.validate_python( + jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) + ) + assert isinstance(request, InitializeRequest) + received_capabilities = request.params.capabilities + + result = InitializeResult( + protocol_version=LATEST_PROTOCOL_VERSION, + capabilities=ServerCapabilities(), + server_info=Implementation(name="mock-server", version="0.1.0"), + ) + + async with server_to_client_send: + await server_to_client_send.send( + SessionMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + # Receive initialized notification + await client_to_server_receive.receive() + + async with ( + ClientSession( + server_to_client_receive, + client_to_server_send, + sampling_callback=custom_sampling_callback, + sampling_capabilities=types.SamplingCapability(tools=types.SamplingToolsCapability()), + ) as session, + anyio.create_task_group() as tg, + client_to_server_send, + client_to_server_receive, + server_to_client_send, + server_to_client_receive, + ): + tg.start_soon(mock_server) + await session.initialize() + + # Assert that sampling capabilities with tools are properly advertised + assert received_capabilities is not None + assert received_capabilities.sampling is not None + assert isinstance(received_capabilities.sampling, types.SamplingCapability) + # Tools capability should be present + assert received_capabilities.sampling.tools is not None + assert isinstance(received_capabilities.sampling.tools, types.SamplingToolsCapability) @pytest.mark.anyio @@ -512,37 +547,33 @@ async def test_get_server_capabilities(): expected_capabilities = ServerCapabilities( logging=types.LoggingCapability(), - prompts=types.PromptsCapability(listChanged=True), - resources=types.ResourcesCapability(subscribe=True, listChanged=True), - tools=types.ToolsCapability(listChanged=False), + prompts=types.PromptsCapability(list_changed=True), + resources=types.ResourcesCapability(subscribe=True, list_changed=True), + tools=types.ToolsCapability(list_changed=False), ) async def mock_server(): session_message = await client_to_server_receive.receive() jsonrpc_request = session_message.message - assert isinstance(jsonrpc_request.root, JSONRPCRequest) - request = ClientRequest.model_validate( + assert isinstance(jsonrpc_request, JSONRPCRequest) + request = client_request_adapter.validate_python( jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) ) - assert isinstance(request.root, InitializeRequest) + assert isinstance(request, InitializeRequest) - result = ServerResult( - InitializeResult( - protocolVersion=LATEST_PROTOCOL_VERSION, - capabilities=expected_capabilities, - serverInfo=Implementation(name="mock-server", version="0.1.0"), - ) + result = InitializeResult( + protocol_version=LATEST_PROTOCOL_VERSION, + capabilities=expected_capabilities, + server_info=Implementation(name="mock-server", version="0.1.0"), ) async with server_to_client_send: await server_to_client_send.send( SessionMessage( - JSONRPCMessage( - JSONRPCResponse( - jsonrpc="2.0", - id=jsonrpc_request.root.id, - result=result.model_dump(by_alias=True, mode="json", exclude_none=True), - ) + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), ) ) ) @@ -569,49 +600,45 @@ async def mock_server(): assert capabilities == expected_capabilities assert capabilities.logging is not None assert capabilities.prompts is not None - assert capabilities.prompts.listChanged is True + assert capabilities.prompts.list_changed is True assert capabilities.resources is not None assert capabilities.resources.subscribe is True assert capabilities.tools is not None - assert capabilities.tools.listChanged is False + assert capabilities.tools.list_changed is False @pytest.mark.anyio @pytest.mark.parametrize(argnames="meta", argvalues=[None, {"toolMeta": "value"}]) -async def test_client_tool_call_with_meta(meta: dict[str, Any] | None): +async def test_client_tool_call_with_meta(meta: RequestParamsMeta | None): """Test that client tool call requests can include metadata""" client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1) server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) - mocked_tool = types.Tool(name="sample_tool", inputSchema={}) + mocked_tool = types.Tool(name="sample_tool", input_schema={}) async def mock_server(): # Receive initialization request from client session_message = await client_to_server_receive.receive() jsonrpc_request = session_message.message - assert isinstance(jsonrpc_request.root, JSONRPCRequest) - request = ClientRequest.model_validate( + assert isinstance(jsonrpc_request, JSONRPCRequest) + request = client_request_adapter.validate_python( jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) ) - assert isinstance(request.root, InitializeRequest) + assert isinstance(request, InitializeRequest) - result = ServerResult( - InitializeResult( - protocolVersion=LATEST_PROTOCOL_VERSION, - capabilities=ServerCapabilities(), - serverInfo=Implementation(name="mock-server", version="0.1.0"), - ) + result = InitializeResult( + protocol_version=LATEST_PROTOCOL_VERSION, + capabilities=ServerCapabilities(), + server_info=Implementation(name="mock-server", version="0.1.0"), ) # Answer initialization request await server_to_client_send.send( SessionMessage( - JSONRPCMessage( - JSONRPCResponse( - jsonrpc="2.0", - id=jsonrpc_request.root.id, - result=result.model_dump(by_alias=True, mode="json", exclude_none=True), - ) + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), ) ) ) @@ -622,28 +649,24 @@ async def mock_server(): # Wait for the client to send a 'tools/call' request session_message = await client_to_server_receive.receive() jsonrpc_request = session_message.message - assert isinstance(jsonrpc_request.root, JSONRPCRequest) + assert isinstance(jsonrpc_request, JSONRPCRequest) - assert jsonrpc_request.root.method == "tools/call" + assert jsonrpc_request.method == "tools/call" if meta is not None: - assert jsonrpc_request.root.params - assert "_meta" in jsonrpc_request.root.params - assert jsonrpc_request.root.params["_meta"] == meta + assert jsonrpc_request.params + assert "_meta" in jsonrpc_request.params + assert jsonrpc_request.params["_meta"] == meta - result = ServerResult( - CallToolResult(content=[TextContent(type="text", text="Called successfully")], isError=False) - ) + result = CallToolResult(content=[TextContent(type="text", text="Called successfully")], is_error=False) # Send the tools/call result await server_to_client_send.send( SessionMessage( - JSONRPCMessage( - JSONRPCResponse( - jsonrpc="2.0", - id=jsonrpc_request.root.id, - result=result.model_dump(by_alias=True, mode="json", exclude_none=True), - ) + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), ) ) ) @@ -652,20 +675,18 @@ async def mock_server(): # The client requires this step to validate the tool output schema session_message = await client_to_server_receive.receive() jsonrpc_request = session_message.message - assert isinstance(jsonrpc_request.root, JSONRPCRequest) + assert isinstance(jsonrpc_request, JSONRPCRequest) - assert jsonrpc_request.root.method == "tools/list" + assert jsonrpc_request.method == "tools/list" result = types.ListToolsResult(tools=[mocked_tool]) await server_to_client_send.send( SessionMessage( - JSONRPCMessage( - JSONRPCResponse( - jsonrpc="2.0", - id=jsonrpc_request.root.id, - result=result.model_dump(by_alias=True, mode="json", exclude_none=True), - ) + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), ) ) ) @@ -673,10 +694,7 @@ async def mock_server(): server_to_client_send.close() async with ( - ClientSession( - server_to_client_receive, - client_to_server_send, - ) as session, + ClientSession(server_to_client_receive, client_to_server_send) as session, anyio.create_task_group() as tg, client_to_server_send, client_to_server_receive, diff --git a/tests/client/test_session_group.py b/tests/client/test_session_group.py index e61ea572b..6a58b39f3 100644 --- a/tests/client/test_session_group.py +++ b/tests/client/test_session_group.py @@ -1,6 +1,7 @@ import contextlib from unittest import mock +import httpx import pytest import mcp @@ -12,7 +13,7 @@ StreamableHttpParameters, ) from mcp.client.stdio import StdioServerParameters -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError @pytest.fixture @@ -24,361 +25,363 @@ def mock_exit_stack(): return mock.MagicMock(spec=contextlib.AsyncExitStack) +def test_client_session_group_init(): + mcp_session_group = ClientSessionGroup() + assert not mcp_session_group._tools + assert not mcp_session_group._resources + assert not mcp_session_group._prompts + assert not mcp_session_group._tool_to_session + + +def test_client_session_group_component_properties(): + # --- Mock Dependencies --- + mock_prompt = mock.Mock() + mock_resource = mock.Mock() + mock_tool = mock.Mock() + + # --- Prepare Session Group --- + mcp_session_group = ClientSessionGroup() + mcp_session_group._prompts = {"my_prompt": mock_prompt} + mcp_session_group._resources = {"my_resource": mock_resource} + mcp_session_group._tools = {"my_tool": mock_tool} + + # --- Assertions --- + assert mcp_session_group.prompts == {"my_prompt": mock_prompt} + assert mcp_session_group.resources == {"my_resource": mock_resource} + assert mcp_session_group.tools == {"my_tool": mock_tool} + + @pytest.mark.anyio -class TestClientSessionGroup: - def test_init(self): - mcp_session_group = ClientSessionGroup() - assert not mcp_session_group._tools - assert not mcp_session_group._resources - assert not mcp_session_group._prompts - assert not mcp_session_group._tool_to_session - - def test_component_properties(self): - # --- Mock Dependencies --- - mock_prompt = mock.Mock() - mock_resource = mock.Mock() - mock_tool = mock.Mock() - - # --- Prepare Session Group --- - mcp_session_group = ClientSessionGroup() - mcp_session_group._prompts = {"my_prompt": mock_prompt} - mcp_session_group._resources = {"my_resource": mock_resource} - mcp_session_group._tools = {"my_tool": mock_tool} - - # --- Assertions --- - assert mcp_session_group.prompts == {"my_prompt": mock_prompt} - assert mcp_session_group.resources == {"my_resource": mock_resource} - assert mcp_session_group.tools == {"my_tool": mock_tool} - - async def test_call_tool(self): - # --- Mock Dependencies --- - mock_session = mock.AsyncMock() - - # --- Prepare Session Group --- - def hook(name: str, server_info: types.Implementation) -> str: # pragma: no cover - return f"{(server_info.name)}-{name}" - - mcp_session_group = ClientSessionGroup(component_name_hook=hook) - mcp_session_group._tools = {"server1-my_tool": types.Tool(name="my_tool", inputSchema={})} - mcp_session_group._tool_to_session = {"server1-my_tool": mock_session} - text_content = types.TextContent(type="text", text="OK") - mock_session.call_tool.return_value = types.CallToolResult(content=[text_content]) - - # --- Test Execution --- - result = await mcp_session_group.call_tool( - name="server1-my_tool", - arguments={ - "name": "value1", - "args": {}, - }, - ) +async def test_client_session_group_call_tool(): + # --- Mock Dependencies --- + mock_session = mock.AsyncMock() + + # --- Prepare Session Group --- + def hook(name: str, server_info: types.Implementation) -> str: # pragma: no cover + return f"{(server_info.name)}-{name}" + + mcp_session_group = ClientSessionGroup(component_name_hook=hook) + mcp_session_group._tools = {"server1-my_tool": types.Tool(name="my_tool", input_schema={})} + mcp_session_group._tool_to_session = {"server1-my_tool": mock_session} + text_content = types.TextContent(type="text", text="OK") + mock_session.call_tool.return_value = types.CallToolResult(content=[text_content]) + + # --- Test Execution --- + result = await mcp_session_group.call_tool( + name="server1-my_tool", + arguments={ + "name": "value1", + "args": {}, + }, + ) + + # --- Assertions --- + assert result.content == [text_content] + mock_session.call_tool.assert_called_once_with( + "my_tool", + arguments={"name": "value1", "args": {}}, + read_timeout_seconds=None, + progress_callback=None, + meta=None, + ) + + +@pytest.mark.anyio +async def test_client_session_group_connect_to_server(mock_exit_stack: contextlib.AsyncExitStack): + """Test connecting to a server and aggregating components.""" + # --- Mock Dependencies --- + mock_server_info = mock.Mock(spec=types.Implementation) + mock_server_info.name = "TestServer1" + mock_session = mock.AsyncMock(spec=mcp.ClientSession) + mock_tool1 = mock.Mock(spec=types.Tool) + mock_tool1.name = "tool_a" + mock_resource1 = mock.Mock(spec=types.Resource) + mock_resource1.name = "resource_b" + mock_prompt1 = mock.Mock(spec=types.Prompt) + mock_prompt1.name = "prompt_c" + mock_session.list_tools.return_value = mock.AsyncMock(tools=[mock_tool1]) + mock_session.list_resources.return_value = mock.AsyncMock(resources=[mock_resource1]) + mock_session.list_prompts.return_value = mock.AsyncMock(prompts=[mock_prompt1]) + + # --- Test Execution --- + group = ClientSessionGroup(exit_stack=mock_exit_stack) + with mock.patch.object(group, "_establish_session", return_value=(mock_server_info, mock_session)): + await group.connect_to_server(StdioServerParameters(command="test")) + + # --- Assertions --- + assert mock_session in group._sessions + assert len(group.tools) == 1 + assert "tool_a" in group.tools + assert group.tools["tool_a"] == mock_tool1 + assert group._tool_to_session["tool_a"] == mock_session + assert len(group.resources) == 1 + assert "resource_b" in group.resources + assert group.resources["resource_b"] == mock_resource1 + assert len(group.prompts) == 1 + assert "prompt_c" in group.prompts + assert group.prompts["prompt_c"] == mock_prompt1 + mock_session.list_tools.assert_awaited_once() + mock_session.list_resources.assert_awaited_once() + mock_session.list_prompts.assert_awaited_once() + + +@pytest.mark.anyio +async def test_client_session_group_connect_to_server_with_name_hook(mock_exit_stack: contextlib.AsyncExitStack): + """Test connecting with a component name hook.""" + # --- Mock Dependencies --- + mock_server_info = mock.Mock(spec=types.Implementation) + mock_server_info.name = "HookServer" + mock_session = mock.AsyncMock(spec=mcp.ClientSession) + mock_tool = mock.Mock(spec=types.Tool) + mock_tool.name = "base_tool" + mock_session.list_tools.return_value = mock.AsyncMock(tools=[mock_tool]) + mock_session.list_resources.return_value = mock.AsyncMock(resources=[]) + mock_session.list_prompts.return_value = mock.AsyncMock(prompts=[]) + + # --- Test Setup --- + def name_hook(name: str, server_info: types.Implementation) -> str: + return f"{server_info.name}.{name}" + + # --- Test Execution --- + group = ClientSessionGroup(exit_stack=mock_exit_stack, component_name_hook=name_hook) + with mock.patch.object(group, "_establish_session", return_value=(mock_server_info, mock_session)): + await group.connect_to_server(StdioServerParameters(command="test")) + + # --- Assertions --- + assert mock_session in group._sessions + assert len(group.tools) == 1 + expected_tool_name = "HookServer.base_tool" + assert expected_tool_name in group.tools + assert group.tools[expected_tool_name] == mock_tool + assert group._tool_to_session[expected_tool_name] == mock_session + - # --- Assertions --- - assert result.content == [text_content] - mock_session.call_tool.assert_called_once_with( - "my_tool", - {"name": "value1", "args": {}}, - read_timeout_seconds=None, - progress_callback=None, - meta=None, +@pytest.mark.anyio +async def test_client_session_group_disconnect_from_server(): + """Test disconnecting from a server.""" + # --- Test Setup --- + group = ClientSessionGroup() + server_name = "ServerToDisconnect" + + # Manually populate state using standard mocks + mock_session1 = mock.MagicMock(spec=mcp.ClientSession) + mock_session2 = mock.MagicMock(spec=mcp.ClientSession) + mock_tool1 = mock.Mock(spec=types.Tool) + mock_tool1.name = "tool1" + mock_resource1 = mock.Mock(spec=types.Resource) + mock_resource1.name = "res1" + mock_prompt1 = mock.Mock(spec=types.Prompt) + mock_prompt1.name = "prm1" + mock_tool2 = mock.Mock(spec=types.Tool) + mock_tool2.name = "tool2" + mock_component_named_like_server = mock.Mock() + mock_session = mock.Mock(spec=mcp.ClientSession) + + group._tools = { + "tool1": mock_tool1, + "tool2": mock_tool2, + server_name: mock_component_named_like_server, + } + group._tool_to_session = { + "tool1": mock_session1, + "tool2": mock_session2, + server_name: mock_session1, + } + group._resources = { + "res1": mock_resource1, + server_name: mock_component_named_like_server, + } + group._prompts = { + "prm1": mock_prompt1, + server_name: mock_component_named_like_server, + } + group._sessions = { + mock_session: ClientSessionGroup._ComponentNames( + prompts=set({"prm1"}), + resources=set({"res1"}), + tools=set({"tool1", "tool2"}), ) + } + + # --- Assertions --- + assert mock_session in group._sessions + assert "tool1" in group._tools + assert "tool2" in group._tools + assert "res1" in group._resources + assert "prm1" in group._prompts + + # --- Test Execution --- + await group.disconnect_from_server(mock_session) + + # --- Assertions --- + assert mock_session not in group._sessions + assert "tool1" not in group._tools + assert "tool2" not in group._tools + assert "res1" not in group._resources + assert "prm1" not in group._prompts - async def test_connect_to_server(self, mock_exit_stack: contextlib.AsyncExitStack): - """Test connecting to a server and aggregating components.""" - # --- Mock Dependencies --- - mock_server_info = mock.Mock(spec=types.Implementation) - mock_server_info.name = "TestServer1" - mock_session = mock.AsyncMock(spec=mcp.ClientSession) - mock_tool1 = mock.Mock(spec=types.Tool) - mock_tool1.name = "tool_a" - mock_resource1 = mock.Mock(spec=types.Resource) - mock_resource1.name = "resource_b" - mock_prompt1 = mock.Mock(spec=types.Prompt) - mock_prompt1.name = "prompt_c" - mock_session.list_tools.return_value = mock.AsyncMock(tools=[mock_tool1]) - mock_session.list_resources.return_value = mock.AsyncMock(resources=[mock_resource1]) - mock_session.list_prompts.return_value = mock.AsyncMock(prompts=[mock_prompt1]) - - # --- Test Execution --- - group = ClientSessionGroup(exit_stack=mock_exit_stack) - with mock.patch.object(group, "_establish_session", return_value=(mock_server_info, mock_session)): - await group.connect_to_server(StdioServerParameters(command="test")) - # --- Assertions --- - assert mock_session in group._sessions - assert len(group.tools) == 1 - assert "tool_a" in group.tools - assert group.tools["tool_a"] == mock_tool1 - assert group._tool_to_session["tool_a"] == mock_session - assert len(group.resources) == 1 - assert "resource_b" in group.resources - assert group.resources["resource_b"] == mock_resource1 - assert len(group.prompts) == 1 - assert "prompt_c" in group.prompts - assert group.prompts["prompt_c"] == mock_prompt1 - mock_session.list_tools.assert_awaited_once() - mock_session.list_resources.assert_awaited_once() - mock_session.list_prompts.assert_awaited_once() - - async def test_connect_to_server_with_name_hook(self, mock_exit_stack: contextlib.AsyncExitStack): - """Test connecting with a component name hook.""" - # --- Mock Dependencies --- - mock_server_info = mock.Mock(spec=types.Implementation) - mock_server_info.name = "HookServer" - mock_session = mock.AsyncMock(spec=mcp.ClientSession) - mock_tool = mock.Mock(spec=types.Tool) - mock_tool.name = "base_tool" - mock_session.list_tools.return_value = mock.AsyncMock(tools=[mock_tool]) - mock_session.list_resources.return_value = mock.AsyncMock(resources=[]) - mock_session.list_prompts.return_value = mock.AsyncMock(prompts=[]) - - # --- Test Setup --- - def name_hook(name: str, server_info: types.Implementation) -> str: - return f"{server_info.name}.{name}" - - # --- Test Execution --- - group = ClientSessionGroup(exit_stack=mock_exit_stack, component_name_hook=name_hook) - with mock.patch.object(group, "_establish_session", return_value=(mock_server_info, mock_session)): +@pytest.mark.anyio +async def test_client_session_group_connect_to_server_duplicate_tool_raises_error( + mock_exit_stack: contextlib.AsyncExitStack, +): + """Test MCPError raised when connecting a server with a dup name.""" + # --- Setup Pre-existing State --- + group = ClientSessionGroup(exit_stack=mock_exit_stack) + existing_tool_name = "shared_tool" + # Manually add a tool to simulate a previous connection + group._tools[existing_tool_name] = mock.Mock(spec=types.Tool) + group._tools[existing_tool_name].name = existing_tool_name + # Need a dummy session associated with the existing tool + mock_session = mock.MagicMock(spec=mcp.ClientSession) + group._tool_to_session[existing_tool_name] = mock_session + group._session_exit_stacks[mock_session] = mock.Mock(spec=contextlib.AsyncExitStack) + + # --- Mock New Connection Attempt --- + mock_server_info_new = mock.Mock(spec=types.Implementation) + mock_server_info_new.name = "ServerWithDuplicate" + mock_session_new = mock.AsyncMock(spec=mcp.ClientSession) + + # Configure the new session to return a tool with the *same name* + duplicate_tool = mock.Mock(spec=types.Tool) + duplicate_tool.name = existing_tool_name + mock_session_new.list_tools.return_value = mock.AsyncMock(tools=[duplicate_tool]) + # Keep other lists empty for simplicity + mock_session_new.list_resources.return_value = mock.AsyncMock(resources=[]) + mock_session_new.list_prompts.return_value = mock.AsyncMock(prompts=[]) + + # --- Test Execution and Assertion --- + with pytest.raises(MCPError) as excinfo: + with mock.patch.object( + group, + "_establish_session", + return_value=(mock_server_info_new, mock_session_new), + ): await group.connect_to_server(StdioServerParameters(command="test")) - # --- Assertions --- - assert mock_session in group._sessions - assert len(group.tools) == 1 - expected_tool_name = "HookServer.base_tool" - assert expected_tool_name in group.tools - assert group.tools[expected_tool_name] == mock_tool - assert group._tool_to_session[expected_tool_name] == mock_session - - async def test_disconnect_from_server(self): # No mock arguments needed - """Test disconnecting from a server.""" - # --- Test Setup --- - group = ClientSessionGroup() - server_name = "ServerToDisconnect" - - # Manually populate state using standard mocks - mock_session1 = mock.MagicMock(spec=mcp.ClientSession) - mock_session2 = mock.MagicMock(spec=mcp.ClientSession) - mock_tool1 = mock.Mock(spec=types.Tool) - mock_tool1.name = "tool1" - mock_resource1 = mock.Mock(spec=types.Resource) - mock_resource1.name = "res1" - mock_prompt1 = mock.Mock(spec=types.Prompt) - mock_prompt1.name = "prm1" - mock_tool2 = mock.Mock(spec=types.Tool) - mock_tool2.name = "tool2" - mock_component_named_like_server = mock.Mock() - mock_session = mock.Mock(spec=mcp.ClientSession) - - group._tools = { - "tool1": mock_tool1, - "tool2": mock_tool2, - server_name: mock_component_named_like_server, - } - group._tool_to_session = { - "tool1": mock_session1, - "tool2": mock_session2, - server_name: mock_session1, - } - group._resources = { - "res1": mock_resource1, - server_name: mock_component_named_like_server, - } - group._prompts = { - "prm1": mock_prompt1, - server_name: mock_component_named_like_server, - } - group._sessions = { - mock_session: ClientSessionGroup._ComponentNames( - prompts=set({"prm1"}), - resources=set({"res1"}), - tools=set({"tool1", "tool2"}), - ) - } - - # --- Assertions --- - assert mock_session in group._sessions - assert "tool1" in group._tools - assert "tool2" in group._tools - assert "res1" in group._resources - assert "prm1" in group._prompts - - # --- Test Execution --- - await group.disconnect_from_server(mock_session) - - # --- Assertions --- - assert mock_session not in group._sessions - assert "tool1" not in group._tools - assert "tool2" not in group._tools - assert "res1" not in group._resources - assert "prm1" not in group._prompts - - async def test_connect_to_server_duplicate_tool_raises_error(self, mock_exit_stack: contextlib.AsyncExitStack): - """Test McpError raised when connecting a server with a dup name.""" - # --- Setup Pre-existing State --- - group = ClientSessionGroup(exit_stack=mock_exit_stack) - existing_tool_name = "shared_tool" - # Manually add a tool to simulate a previous connection - group._tools[existing_tool_name] = mock.Mock(spec=types.Tool) - group._tools[existing_tool_name].name = existing_tool_name - # Need a dummy session associated with the existing tool - mock_session = mock.MagicMock(spec=mcp.ClientSession) - group._tool_to_session[existing_tool_name] = mock_session - group._session_exit_stacks[mock_session] = mock.Mock(spec=contextlib.AsyncExitStack) - - # --- Mock New Connection Attempt --- - mock_server_info_new = mock.Mock(spec=types.Implementation) - mock_server_info_new.name = "ServerWithDuplicate" - mock_session_new = mock.AsyncMock(spec=mcp.ClientSession) - - # Configure the new session to return a tool with the *same name* - duplicate_tool = mock.Mock(spec=types.Tool) - duplicate_tool.name = existing_tool_name - mock_session_new.list_tools.return_value = mock.AsyncMock(tools=[duplicate_tool]) - # Keep other lists empty for simplicity - mock_session_new.list_resources.return_value = mock.AsyncMock(resources=[]) - mock_session_new.list_prompts.return_value = mock.AsyncMock(prompts=[]) - - # --- Test Execution and Assertion --- - with pytest.raises(McpError) as excinfo: - with mock.patch.object( - group, - "_establish_session", - return_value=(mock_server_info_new, mock_session_new), - ): - await group.connect_to_server(StdioServerParameters(command="test")) - - # Assert details about the raised error - assert excinfo.value.error.code == types.INVALID_PARAMS - assert existing_tool_name in excinfo.value.error.message - assert "already exist " in excinfo.value.error.message - - # Verify the duplicate tool was *not* added again (state should be unchanged) - assert len(group._tools) == 1 # Should still only have the original - assert group._tools[existing_tool_name] is not duplicate_tool # Ensure it's the original mock - - # No patching needed here - async def test_disconnect_non_existent_server(self): - """Test disconnecting a server that isn't connected.""" - session = mock.Mock(spec=mcp.ClientSession) - group = ClientSessionGroup() - with pytest.raises(McpError): - await group.disconnect_from_server(session) - - @pytest.mark.parametrize( - "server_params_instance, client_type_name, patch_target_for_client_func", - [ - ( - StdioServerParameters(command="test_stdio_cmd"), - "stdio", - "mcp.client.session_group.mcp.stdio_client", - ), - ( - SseServerParameters(url="http://test.com/sse", timeout=10), - "sse", - "mcp.client.session_group.sse_client", - ), # url, headers, timeout, sse_read_timeout - ( - StreamableHttpParameters(url="http://test.com/stream", terminate_on_close=False), - "streamablehttp", - "mcp.client.session_group.streamablehttp_client", - ), # url, headers, timeout, sse_read_timeout, terminate_on_close - ], - ) - async def test_establish_session_parameterized( - self, - server_params_instance: StdioServerParameters | SseServerParameters | StreamableHttpParameters, - client_type_name: str, # Just for clarity or conditional logic if needed - patch_target_for_client_func: str, - ): - with mock.patch("mcp.client.session_group.mcp.ClientSession") as mock_ClientSession_class: - with mock.patch(patch_target_for_client_func) as mock_specific_client_func: - mock_client_cm_instance = mock.AsyncMock(name=f"{client_type_name}ClientCM") - mock_read_stream = mock.AsyncMock(name=f"{client_type_name}Read") - mock_write_stream = mock.AsyncMock(name=f"{client_type_name}Write") - - # streamablehttp_client's __aenter__ returns three values - if client_type_name == "streamablehttp": - mock_extra_stream_val = mock.AsyncMock(name="StreamableExtra") - mock_client_cm_instance.__aenter__.return_value = ( - mock_read_stream, - mock_write_stream, - mock_extra_stream_val, - ) - else: - mock_client_cm_instance.__aenter__.return_value = ( - mock_read_stream, - mock_write_stream, - ) - - mock_client_cm_instance.__aexit__ = mock.AsyncMock(return_value=None) - mock_specific_client_func.return_value = mock_client_cm_instance - - # --- Mock mcp.ClientSession (class) --- - # mock_ClientSession_class is already provided by the outer patch - mock_raw_session_cm = mock.AsyncMock(name="RawSessionCM") - mock_ClientSession_class.return_value = mock_raw_session_cm - - mock_entered_session = mock.AsyncMock(name="EnteredSessionInstance") - mock_raw_session_cm.__aenter__.return_value = mock_entered_session - mock_raw_session_cm.__aexit__ = mock.AsyncMock(return_value=None) - - # Mock session.initialize() - mock_initialize_result = mock.AsyncMock(name="InitializeResult") - mock_initialize_result.serverInfo = types.Implementation(name="foo", version="1") - mock_entered_session.initialize.return_value = mock_initialize_result - - # --- Test Execution --- - group = ClientSessionGroup() - returned_server_info = None - returned_session = None - - async with contextlib.AsyncExitStack() as stack: - group._exit_stack = stack - ( - returned_server_info, - returned_session, - ) = await group._establish_session(server_params_instance, ClientSessionParameters()) - - # --- Assertions --- - # 1. Assert the correct specific client function was called - if client_type_name == "stdio": - assert isinstance(server_params_instance, StdioServerParameters) - mock_specific_client_func.assert_called_once_with(server_params_instance) - elif client_type_name == "sse": - assert isinstance(server_params_instance, SseServerParameters) - mock_specific_client_func.assert_called_once_with( - url=server_params_instance.url, - headers=server_params_instance.headers, - timeout=server_params_instance.timeout, - sse_read_timeout=server_params_instance.sse_read_timeout, - ) - elif client_type_name == "streamablehttp": # pragma: no branch - assert isinstance(server_params_instance, StreamableHttpParameters) - mock_specific_client_func.assert_called_once_with( - url=server_params_instance.url, - headers=server_params_instance.headers, - timeout=server_params_instance.timeout, - sse_read_timeout=server_params_instance.sse_read_timeout, - terminate_on_close=server_params_instance.terminate_on_close, - ) - - mock_client_cm_instance.__aenter__.assert_awaited_once() - - # 2. Assert ClientSession was called correctly - mock_ClientSession_class.assert_called_once_with( - mock_read_stream, - mock_write_stream, - read_timeout_seconds=None, - sampling_callback=None, - elicitation_callback=None, - list_roots_callback=None, - logging_callback=None, - message_handler=None, - client_info=None, + # Assert details about the raised error + assert excinfo.value.error.code == types.INVALID_PARAMS + assert existing_tool_name in excinfo.value.error.message + assert "already exist " in excinfo.value.error.message + + # Verify the duplicate tool was *not* added again (state should be unchanged) + assert len(group._tools) == 1 # Should still only have the original + assert group._tools[existing_tool_name] is not duplicate_tool # Ensure it's the original mock + + +@pytest.mark.anyio +async def test_client_session_group_disconnect_non_existent_server(): + """Test disconnecting a server that isn't connected.""" + session = mock.Mock(spec=mcp.ClientSession) + group = ClientSessionGroup() + with pytest.raises(MCPError): + await group.disconnect_from_server(session) + + +# TODO(Marcelo): This is horrible. We should drop this test. +@pytest.mark.anyio +@pytest.mark.parametrize( + "server_params_instance, client_type_name, patch_target_for_client_func", + [ + ( + StdioServerParameters(command="test_stdio_cmd"), + "stdio", + "mcp.client.session_group.mcp.stdio_client", + ), + ( + SseServerParameters(url="http://test.com/sse", timeout=10.0), + "sse", + "mcp.client.session_group.sse_client", + ), # url, headers, timeout, sse_read_timeout + ( + StreamableHttpParameters(url="http://test.com/stream", terminate_on_close=False), + "streamablehttp", + "mcp.client.session_group.streamable_http_client", + ), # url, headers, timeout, sse_read_timeout, terminate_on_close + ], +) +async def test_client_session_group_establish_session_parameterized( + server_params_instance: StdioServerParameters | SseServerParameters | StreamableHttpParameters, + client_type_name: str, # Just for clarity or conditional logic if needed + patch_target_for_client_func: str, +): + with mock.patch("mcp.client.session_group.mcp.ClientSession") as mock_ClientSession_class: + with mock.patch(patch_target_for_client_func) as mock_specific_client_func: + mock_client_cm_instance = mock.AsyncMock(name=f"{client_type_name}ClientCM") + mock_read_stream = mock.AsyncMock(name=f"{client_type_name}Read") + mock_write_stream = mock.AsyncMock(name=f"{client_type_name}Write") + + # All client context managers return (read_stream, write_stream) + mock_client_cm_instance.__aenter__.return_value = (mock_read_stream, mock_write_stream) + + mock_client_cm_instance.__aexit__ = mock.AsyncMock(return_value=None) + mock_specific_client_func.return_value = mock_client_cm_instance + + # --- Mock mcp.ClientSession (class) --- + # mock_ClientSession_class is already provided by the outer patch + mock_raw_session_cm = mock.AsyncMock(name="RawSessionCM") + mock_ClientSession_class.return_value = mock_raw_session_cm + + mock_entered_session = mock.AsyncMock(name="EnteredSessionInstance") + mock_raw_session_cm.__aenter__.return_value = mock_entered_session + mock_raw_session_cm.__aexit__ = mock.AsyncMock(return_value=None) + + # Mock session.initialize() + mock_initialize_result = mock.AsyncMock(name="InitializeResult") + mock_initialize_result.server_info = types.Implementation(name="foo", version="1") + mock_entered_session.initialize.return_value = mock_initialize_result + + # --- Test Execution --- + group = ClientSessionGroup() + returned_server_info = None + returned_session = None + + async with contextlib.AsyncExitStack() as stack: + group._exit_stack = stack + ( + returned_server_info, + returned_session, + ) = await group._establish_session(server_params_instance, ClientSessionParameters()) + + # --- Assertions --- + # 1. Assert the correct specific client function was called + if client_type_name == "stdio": + assert isinstance(server_params_instance, StdioServerParameters) + mock_specific_client_func.assert_called_once_with(server_params_instance) + elif client_type_name == "sse": + assert isinstance(server_params_instance, SseServerParameters) + mock_specific_client_func.assert_called_once_with( + url=server_params_instance.url, + headers=server_params_instance.headers, + timeout=server_params_instance.timeout, + sse_read_timeout=server_params_instance.sse_read_timeout, ) - mock_raw_session_cm.__aenter__.assert_awaited_once() - mock_entered_session.initialize.assert_awaited_once() + elif client_type_name == "streamablehttp": # pragma: no branch + assert isinstance(server_params_instance, StreamableHttpParameters) + # Verify streamable_http_client was called with url, httpx_client, and terminate_on_close + # The http_client is created by the real create_mcp_http_client + call_args = mock_specific_client_func.call_args + assert call_args.kwargs["url"] == server_params_instance.url + assert call_args.kwargs["terminate_on_close"] == server_params_instance.terminate_on_close + assert isinstance(call_args.kwargs["http_client"], httpx.AsyncClient) + + mock_client_cm_instance.__aenter__.assert_awaited_once() + + # 2. Assert ClientSession was called correctly + mock_ClientSession_class.assert_called_once_with( + mock_read_stream, + mock_write_stream, + read_timeout_seconds=None, + sampling_callback=None, + elicitation_callback=None, + list_roots_callback=None, + logging_callback=None, + message_handler=None, + client_info=None, + ) + mock_raw_session_cm.__aenter__.assert_awaited_once() + mock_entered_session.initialize.assert_awaited_once() - # 3. Assert returned values - assert returned_server_info is mock_initialize_result.serverInfo - assert returned_session is mock_entered_session + # 3. Assert returned values + assert returned_server_info is mock_initialize_result.server_info + assert returned_session is mock_entered_session diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index ce6c85962..f70c24eee 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -10,8 +10,13 @@ import pytest from mcp.client.session import ClientSession -from mcp.client.stdio import StdioServerParameters, _create_platform_compatible_process, stdio_client -from mcp.shared.exceptions import McpError +from mcp.client.stdio import ( + StdioServerParameters, + _create_platform_compatible_process, + _terminate_process_tree, + stdio_client, +) +from mcp.shared.exceptions import MCPError from mcp.shared.message import SessionMessage from mcp.types import CONNECTION_CLOSED, JSONRPCMessage, JSONRPCRequest, JSONRPCResponse @@ -42,8 +47,8 @@ async def test_stdio_client(): async with stdio_client(server_parameters) as (read_stream, write_stream): # Test sending and receiving messages messages = [ - JSONRPCMessage(root=JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")), - JSONRPCMessage(root=JSONRPCResponse(jsonrpc="2.0", id=2, result={})), + JSONRPCRequest(jsonrpc="2.0", id=1, method="ping"), + JSONRPCResponse(jsonrpc="2.0", id=2, result={}), ] async with write_stream: @@ -62,18 +67,18 @@ async def test_stdio_client(): break assert len(read_messages) == 2 - assert read_messages[0] == JSONRPCMessage(root=JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")) - assert read_messages[1] == JSONRPCMessage(root=JSONRPCResponse(jsonrpc="2.0", id=2, result={})) + assert read_messages[0] == JSONRPCRequest(jsonrpc="2.0", id=1, method="ping") + assert read_messages[1] == JSONRPCResponse(jsonrpc="2.0", id=2, result={}) @pytest.mark.anyio async def test_stdio_client_bad_path(): """Check that the connection doesn't hang if process errors.""" - server_params = StdioServerParameters(command="python", args=["-c", "non-existent-file.py"]) + server_params = StdioServerParameters(command=sys.executable, args=["-c", "non-existent-file.py"]) async with stdio_client(server_params) as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: # The session should raise an error when the connection closes - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: await session.initialize() # Check that we got a connection closed error @@ -101,8 +106,7 @@ async def test_stdio_client_nonexistent_command(): @pytest.mark.anyio async def test_stdio_client_universal_cleanup(): - """ - Test that stdio_client completes cleanup within reasonable time + """Test that stdio_client completes cleanup within reasonable time even when connected to processes that exit slowly. """ @@ -153,10 +157,8 @@ async def test_stdio_client_universal_cleanup(): @pytest.mark.anyio @pytest.mark.skipif(sys.platform == "win32", reason="Windows signal handling is different") -async def test_stdio_client_sigint_only_process(): # pragma: no cover - """ - Test cleanup with a process that ignores SIGTERM but responds to SIGINT. - """ +async def test_stdio_client_sigint_only_process(): # pragma: lax no cover + """Test cleanup with a process that ignores SIGTERM but responds to SIGINT.""" # Create a Python script that ignores SIGTERM but handles SIGINT script_content = textwrap.dedent( """ @@ -220,8 +222,7 @@ def sigint_handler(signum, frame): class TestChildProcessCleanup: - """ - Tests for child process cleanup functionality using _terminate_process_tree. + """Tests for child process cleanup functionality using _terminate_process_tree. These tests verify that child processes are properly terminated when the parent is killed, addressing the issue where processes like npx spawn child processes @@ -247,8 +248,7 @@ class TestChildProcessCleanup: @pytest.mark.anyio @pytest.mark.filterwarnings("ignore::ResourceWarning" if sys.platform == "win32" else "default") async def test_basic_child_process_cleanup(self): - """ - Test basic parent-child process cleanup. + """Test basic parent-child process cleanup. Parent spawns a single child process that writes continuously to a file. """ # Create a marker file for the child process to write to @@ -312,8 +312,6 @@ async def test_basic_child_process_cleanup(self): # Terminate using our function print("Terminating process and children...") - from mcp.client.stdio import _terminate_process_tree - await _terminate_process_tree(proc) # Verify processes stopped @@ -341,8 +339,7 @@ async def test_basic_child_process_cleanup(self): @pytest.mark.anyio @pytest.mark.filterwarnings("ignore::ResourceWarning" if sys.platform == "win32" else "default") async def test_nested_process_tree(self): - """ - Test nested process tree cleanup (parent → child → grandchild). + """Test nested process tree cleanup (parent → child → grandchild). Each level writes to a different file to verify all processes are terminated. """ # Create temporary files for each process level @@ -413,8 +410,6 @@ async def test_nested_process_tree(self): assert new_size > initial_size, f"{name} process should be writing" # Terminate the whole tree - from mcp.client.stdio import _terminate_process_tree - await _terminate_process_tree(proc) # Verify all stopped @@ -439,8 +434,7 @@ async def test_nested_process_tree(self): @pytest.mark.anyio @pytest.mark.filterwarnings("ignore::ResourceWarning" if sys.platform == "win32" else "default") async def test_early_parent_exit(self): - """ - Test cleanup when parent exits during termination sequence. + """Test cleanup when parent exits during termination sequence. Tests the race condition where parent might die during our termination sequence but we can still clean up the children via the process group. """ @@ -487,15 +481,13 @@ def handle_term(sig, frame): await anyio.sleep(0.5) # Verify child is writing - if os.path.exists(marker_file): # pragma: no cover + if os.path.exists(marker_file): # pragma: no branch size1 = os.path.getsize(marker_file) await anyio.sleep(0.3) size2 = os.path.getsize(marker_file) assert size2 > size1, "Child should be writing" # Terminate - this will kill the process group even if parent exits first - from mcp.client.stdio import _terminate_process_tree - await _terminate_process_tree(proc) # Verify child stopped @@ -518,8 +510,7 @@ def handle_term(sig, frame): @pytest.mark.anyio async def test_stdio_client_graceful_stdin_exit(): - """ - Test that a process exits gracefully when stdin is closed, + """Test that a process exits gracefully when stdin is closed, without needing SIGTERM or SIGKILL. """ # Create a Python script that exits when stdin is closed @@ -574,8 +565,7 @@ async def test_stdio_client_graceful_stdin_exit(): @pytest.mark.anyio async def test_stdio_client_stdin_close_ignored(): - """ - Test that when a process ignores stdin closure, the shutdown sequence + """Test that when a process ignores stdin closure, the shutdown sequence properly escalates to SIGTERM. """ # Create a Python script that ignores stdin closure but responds to SIGTERM diff --git a/tests/server/fastmcp/prompts/__init__.py b/tests/client/transports/__init__.py similarity index 100% rename from tests/server/fastmcp/prompts/__init__.py rename to tests/client/transports/__init__.py diff --git a/tests/client/transports/test_memory.py b/tests/client/transports/test_memory.py new file mode 100644 index 000000000..47be3e208 --- /dev/null +++ b/tests/client/transports/test_memory.py @@ -0,0 +1,97 @@ +"""Tests for InMemoryTransport.""" + +import pytest + +from mcp import Client, types +from mcp.client._memory import InMemoryTransport +from mcp.server import Server, ServerRequestContext +from mcp.server.mcpserver import MCPServer +from mcp.types import ListResourcesResult, Resource + + +@pytest.fixture +def simple_server() -> Server: + """Create a simple MCP server for testing.""" + + async def handle_list_resources( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> ListResourcesResult: # pragma: no cover + return ListResourcesResult( + resources=[ + Resource( + uri="memory://test", + name="Test Resource", + description="A test resource", + ) + ] + ) + + return Server(name="test_server", on_list_resources=handle_list_resources) + + +@pytest.fixture +def mcpserver_server() -> MCPServer: + """Create an MCPServer server for testing.""" + server = MCPServer("test") + + @server.tool() + def greet(name: str) -> str: + """Greet someone by name.""" + return f"Hello, {name}!" + + @server.resource("test://resource") + def test_resource() -> str: # pragma: no cover + """A test resource.""" + return "Test content" + + return server + + +pytestmark = pytest.mark.anyio + + +async def test_with_server(simple_server: Server): + """Test creating transport with a Server instance.""" + transport = InMemoryTransport(simple_server) + async with transport as (read_stream, write_stream): + assert read_stream is not None + assert write_stream is not None + + +async def test_with_mcpserver(mcpserver_server: MCPServer): + """Test creating transport with an MCPServer instance.""" + transport = InMemoryTransport(mcpserver_server) + async with transport as (read_stream, write_stream): + assert read_stream is not None + assert write_stream is not None + + +async def test_server_is_running(mcpserver_server: MCPServer): + """Test that the server is running and responding to requests.""" + async with Client(mcpserver_server) as client: + assert client.server_capabilities is not None + + +async def test_list_tools(mcpserver_server: MCPServer): + """Test listing tools through the transport.""" + async with Client(mcpserver_server) as client: + tools_result = await client.list_tools() + assert len(tools_result.tools) > 0 + tool_names = [t.name for t in tools_result.tools] + assert "greet" in tool_names + + +async def test_call_tool(mcpserver_server: MCPServer): + """Test calling a tool through the transport.""" + async with Client(mcpserver_server) as client: + result = await client.call_tool("greet", {"name": "World"}) + assert result is not None + assert len(result.content) > 0 + assert "Hello, World!" in str(result.content[0]) + + +async def test_raise_exceptions(mcpserver_server: MCPServer): + """Test that raise_exceptions parameter is passed through.""" + transport = InMemoryTransport(mcpserver_server, raise_exceptions=True) + async with transport as (read_stream, _write_stream): + assert read_stream is not None diff --git a/tests/experimental/tasks/client/test_capabilities.py b/tests/experimental/tasks/client/test_capabilities.py index f2def4e3a..1ea2199e8 100644 --- a/tests/experimental/tasks/client/test_capabilities.py +++ b/tests/experimental/tasks/client/test_capabilities.py @@ -3,23 +3,20 @@ import anyio import pytest -import mcp.types as types -from mcp import ClientCapabilities +from mcp import ClientCapabilities, types from mcp.client.experimental.task_handlers import ExperimentalTaskHandlers from mcp.client.session import ClientSession -from mcp.shared.context import RequestContext +from mcp.shared._context import RequestContext from mcp.shared.message import SessionMessage from mcp.types import ( LATEST_PROTOCOL_VERSION, - ClientRequest, Implementation, InitializeRequest, InitializeResult, - JSONRPCMessage, JSONRPCRequest, JSONRPCResponse, ServerCapabilities, - ServerResult, + client_request_adapter, ) @@ -36,30 +33,26 @@ async def mock_server(): session_message = await client_to_server_receive.receive() jsonrpc_request = session_message.message - assert isinstance(jsonrpc_request.root, JSONRPCRequest) - request = ClientRequest.model_validate( + assert isinstance(jsonrpc_request, JSONRPCRequest) + request = client_request_adapter.validate_python( jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) ) - assert isinstance(request.root, InitializeRequest) - received_capabilities = request.root.params.capabilities - - result = ServerResult( - InitializeResult( - protocolVersion=LATEST_PROTOCOL_VERSION, - capabilities=ServerCapabilities(), - serverInfo=Implementation(name="mock-server", version="0.1.0"), - ) + assert isinstance(request, InitializeRequest) + received_capabilities = request.params.capabilities + + result = InitializeResult( + protocol_version=LATEST_PROTOCOL_VERSION, + capabilities=ServerCapabilities(), + server_info=Implementation(name="mock-server", version="0.1.0"), ) async with server_to_client_send: await server_to_client_send.send( SessionMessage( - JSONRPCMessage( - JSONRPCResponse( - jsonrpc="2.0", - id=jsonrpc_request.root.id, - result=result.model_dump(by_alias=True, mode="json", exclude_none=True), - ) + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), ) ) ) @@ -94,13 +87,13 @@ async def test_client_capabilities_with_tasks(): # Define custom handlers to trigger capability building (never actually called) async def my_list_tasks_handler( - context: RequestContext[ClientSession, None], + context: RequestContext[ClientSession], params: types.PaginatedRequestParams | None, ) -> types.ListTasksResult | types.ErrorData: raise NotImplementedError async def my_cancel_task_handler( - context: RequestContext[ClientSession, None], + context: RequestContext[ClientSession], params: types.CancelTaskRequestParams, ) -> types.CancelTaskResult | types.ErrorData: raise NotImplementedError @@ -110,30 +103,26 @@ async def mock_server(): session_message = await client_to_server_receive.receive() jsonrpc_request = session_message.message - assert isinstance(jsonrpc_request.root, JSONRPCRequest) - request = ClientRequest.model_validate( + assert isinstance(jsonrpc_request, JSONRPCRequest) + request = client_request_adapter.validate_python( jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) ) - assert isinstance(request.root, InitializeRequest) - received_capabilities = request.root.params.capabilities - - result = ServerResult( - InitializeResult( - protocolVersion=LATEST_PROTOCOL_VERSION, - capabilities=ServerCapabilities(), - serverInfo=Implementation(name="mock-server", version="0.1.0"), - ) + assert isinstance(request, InitializeRequest) + received_capabilities = request.params.capabilities + + result = InitializeResult( + protocol_version=LATEST_PROTOCOL_VERSION, + capabilities=ServerCapabilities(), + server_info=Implementation(name="mock-server", version="0.1.0"), ) async with server_to_client_send: await server_to_client_send.send( SessionMessage( - JSONRPCMessage( - JSONRPCResponse( - jsonrpc="2.0", - id=jsonrpc_request.root.id, - result=result.model_dump(by_alias=True, mode="json", exclude_none=True), - ) + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), ) ) ) @@ -178,13 +167,13 @@ async def test_client_capabilities_auto_built_from_handlers(): # Define custom handlers (not defaults) async def my_list_tasks_handler( - context: RequestContext[ClientSession, None], + context: RequestContext[ClientSession], params: types.PaginatedRequestParams | None, ) -> types.ListTasksResult | types.ErrorData: raise NotImplementedError async def my_cancel_task_handler( - context: RequestContext[ClientSession, None], + context: RequestContext[ClientSession], params: types.CancelTaskRequestParams, ) -> types.CancelTaskResult | types.ErrorData: raise NotImplementedError @@ -194,30 +183,26 @@ async def mock_server(): session_message = await client_to_server_receive.receive() jsonrpc_request = session_message.message - assert isinstance(jsonrpc_request.root, JSONRPCRequest) - request = ClientRequest.model_validate( + assert isinstance(jsonrpc_request, JSONRPCRequest) + request = client_request_adapter.validate_python( jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) ) - assert isinstance(request.root, InitializeRequest) - received_capabilities = request.root.params.capabilities - - result = ServerResult( - InitializeResult( - protocolVersion=LATEST_PROTOCOL_VERSION, - capabilities=ServerCapabilities(), - serverInfo=Implementation(name="mock-server", version="0.1.0"), - ) + assert isinstance(request, InitializeRequest) + received_capabilities = request.params.capabilities + + result = InitializeResult( + protocol_version=LATEST_PROTOCOL_VERSION, + capabilities=ServerCapabilities(), + server_info=Implementation(name="mock-server", version="0.1.0"), ) async with server_to_client_send: await server_to_client_send.send( SessionMessage( - JSONRPCMessage( - JSONRPCResponse( - jsonrpc="2.0", - id=jsonrpc_request.root.id, - result=result.model_dump(by_alias=True, mode="json", exclude_none=True), - ) + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), ) ) ) @@ -263,7 +248,7 @@ async def test_client_capabilities_with_task_augmented_handlers(): # Define task-augmented handler async def my_augmented_sampling_handler( - context: RequestContext[ClientSession, None], + context: RequestContext[ClientSession], params: types.CreateMessageRequestParams, task_metadata: types.TaskMetadata, ) -> types.CreateTaskResult | types.ErrorData: @@ -274,30 +259,26 @@ async def mock_server(): session_message = await client_to_server_receive.receive() jsonrpc_request = session_message.message - assert isinstance(jsonrpc_request.root, JSONRPCRequest) - request = ClientRequest.model_validate( + assert isinstance(jsonrpc_request, JSONRPCRequest) + request = client_request_adapter.validate_python( jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) ) - assert isinstance(request.root, InitializeRequest) - received_capabilities = request.root.params.capabilities - - result = ServerResult( - InitializeResult( - protocolVersion=LATEST_PROTOCOL_VERSION, - capabilities=ServerCapabilities(), - serverInfo=Implementation(name="mock-server", version="0.1.0"), - ) + assert isinstance(request, InitializeRequest) + received_capabilities = request.params.capabilities + + result = InitializeResult( + protocol_version=LATEST_PROTOCOL_VERSION, + capabilities=ServerCapabilities(), + server_info=Implementation(name="mock-server", version="0.1.0"), ) async with server_to_client_send: await server_to_client_send.send( SessionMessage( - JSONRPCMessage( - JSONRPCResponse( - jsonrpc="2.0", - id=jsonrpc_request.root.id, - result=result.model_dump(by_alias=True, mode="json", exclude_none=True), - ) + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), ) ) ) diff --git a/tests/experimental/tasks/client/test_handlers.py b/tests/experimental/tasks/client/test_handlers.py index 86cea42ae..137ff8010 100644 --- a/tests/experimental/tasks/client/test_handlers.py +++ b/tests/experimental/tasks/client/test_handlers.py @@ -19,10 +19,10 @@ from anyio.abc import TaskGroup from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -import mcp.types as types +from mcp import types from mcp.client.experimental.task_handlers import ExperimentalTaskHandlers from mcp.client.session import ClientSession -from mcp.shared.context import RequestContext +from mcp.shared._context import RequestContext from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore from mcp.shared.message import SessionMessage from mcp.shared.session import RequestResponder @@ -113,21 +113,21 @@ async def test_client_handles_get_task_request(client_streams: ClientTestStreams received_task_id: str | None = None async def get_task_handler( - context: RequestContext[ClientSession, None], + context: RequestContext[ClientSession], params: GetTaskRequestParams, ) -> GetTaskResult | ErrorData: nonlocal received_task_id - received_task_id = params.taskId - task = await store.get_task(params.taskId) - assert task is not None, f"Test setup error: task {params.taskId} should exist" + received_task_id = params.task_id + task = await store.get_task(params.task_id) + assert task is not None, f"Test setup error: task {params.task_id} should exist" return GetTaskResult( - taskId=task.taskId, + task_id=task.task_id, status=task.status, - statusMessage=task.statusMessage, - createdAt=task.createdAt, - lastUpdatedAt=task.lastUpdatedAt, + status_message=task.status_message, + created_at=task.created_at, + last_updated_at=task.last_updated_at, ttl=task.ttl, - pollInterval=task.pollInterval, + poll_interval=task.poll_interval, ) await store.create_task(TaskMetadata(ttl=60000), task_id="test-task-123") @@ -150,21 +150,17 @@ async def run_client() -> None: tg.start_soon(run_client) await client_ready.wait() - typed_request = GetTaskRequest(params=GetTaskRequestParams(taskId="test-task-123")) - request = types.JSONRPCRequest( - jsonrpc="2.0", - id="req-1", - **typed_request.model_dump(by_alias=True), - ) - await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request))) + typed_request = GetTaskRequest(params=GetTaskRequestParams(task_id="test-task-123")) + request = types.JSONRPCRequest(jsonrpc="2.0", id="req-1", **typed_request.model_dump(by_alias=True)) + await client_streams.server_send.send(SessionMessage(request)) response_msg = await client_streams.server_receive.receive() - response = response_msg.message.root + response = response_msg.message assert isinstance(response, types.JSONRPCResponse) assert response.id == "req-1" result = GetTaskResult.model_validate(response.result) - assert result.taskId == "test-task-123" + assert result.task_id == "test-task-123" assert result.status == "working" assert received_task_id == "test-task-123" @@ -180,11 +176,11 @@ async def test_client_handles_get_task_result_request(client_streams: ClientTest store = InMemoryTaskStore() async def get_task_result_handler( - context: RequestContext[ClientSession, None], + context: RequestContext[ClientSession], params: GetTaskPayloadRequestParams, ) -> GetTaskPayloadResult | ErrorData: - result = await store.get_result(params.taskId) - assert result is not None, f"Test setup error: result for {params.taskId} should exist" + result = await store.get_result(params.task_id) + assert result is not None, f"Test setup error: result for {params.task_id} should exist" assert isinstance(result, types.CallToolResult) return GetTaskPayloadResult(**result.model_dump()) @@ -213,16 +209,16 @@ async def run_client() -> None: tg.start_soon(run_client) await client_ready.wait() - typed_request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId="test-task-456")) + typed_request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(task_id="test-task-456")) request = types.JSONRPCRequest( jsonrpc="2.0", id="req-2", **typed_request.model_dump(by_alias=True), ) - await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request))) + await client_streams.server_send.send(SessionMessage(request)) response_msg = await client_streams.server_receive.receive() - response = response_msg.message.root + response = response_msg.message assert isinstance(response, types.JSONRPCResponse) assert isinstance(response.result, dict) @@ -243,12 +239,12 @@ async def test_client_handles_list_tasks_request(client_streams: ClientTestStrea store = InMemoryTaskStore() async def list_tasks_handler( - context: RequestContext[ClientSession, None], + context: RequestContext[ClientSession], params: types.PaginatedRequestParams | None, ) -> ListTasksResult | ErrorData: cursor = params.cursor if params else None tasks_list, next_cursor = await store.list_tasks(cursor=cursor) - return ListTasksResult(tasks=tasks_list, nextCursor=next_cursor) + return ListTasksResult(tasks=tasks_list, next_cursor=next_cursor) await store.create_task(TaskMetadata(ttl=60000), task_id="task-1") await store.create_task(TaskMetadata(ttl=60000), task_id="task-2") @@ -277,10 +273,10 @@ async def run_client() -> None: id="req-3", **typed_request.model_dump(by_alias=True), ) - await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request))) + await client_streams.server_send.send(SessionMessage(request)) response_msg = await client_streams.server_receive.receive() - response = response_msg.message.root + response = response_msg.message assert isinstance(response, types.JSONRPCResponse) result = ListTasksResult.model_validate(response.result) @@ -298,19 +294,19 @@ async def test_client_handles_cancel_task_request(client_streams: ClientTestStre store = InMemoryTaskStore() async def cancel_task_handler( - context: RequestContext[ClientSession, None], + context: RequestContext[ClientSession], params: CancelTaskRequestParams, ) -> CancelTaskResult | ErrorData: - task = await store.get_task(params.taskId) - assert task is not None, f"Test setup error: task {params.taskId} should exist" - await store.update_task(params.taskId, status="cancelled") - updated = await store.get_task(params.taskId) + task = await store.get_task(params.task_id) + assert task is not None, f"Test setup error: task {params.task_id} should exist" + await store.update_task(params.task_id, status="cancelled") + updated = await store.get_task(params.task_id) assert updated is not None return CancelTaskResult( - taskId=updated.taskId, + task_id=updated.task_id, status=updated.status, - createdAt=updated.createdAt, - lastUpdatedAt=updated.lastUpdatedAt, + created_at=updated.created_at, + last_updated_at=updated.last_updated_at, ttl=updated.ttl, ) @@ -334,20 +330,20 @@ async def run_client() -> None: tg.start_soon(run_client) await client_ready.wait() - typed_request = CancelTaskRequest(params=CancelTaskRequestParams(taskId="task-to-cancel")) + typed_request = CancelTaskRequest(params=CancelTaskRequestParams(task_id="task-to-cancel")) request = types.JSONRPCRequest( jsonrpc="2.0", id="req-4", **typed_request.model_dump(by_alias=True), ) - await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request))) + await client_streams.server_send.send(SessionMessage(request)) response_msg = await client_streams.server_receive.receive() - response = response_msg.message.root + response = response_msg.message assert isinstance(response, types.JSONRPCResponse) result = CancelTaskResult.model_validate(response.result) - assert result.taskId == "task-to-cancel" + assert result.task_id == "task-to-cancel" assert result.status == "cancelled" tg.cancel_scope.cancel() @@ -365,22 +361,22 @@ async def test_client_task_augmented_sampling(client_streams: ClientTestStreams) background_tg: list[TaskGroup | None] = [None] async def task_augmented_sampling_callback( - context: RequestContext[ClientSession, None], + context: RequestContext[ClientSession], params: CreateMessageRequestParams, task_metadata: TaskMetadata, ) -> CreateTaskResult: task = await store.create_task(task_metadata) - created_task_id[0] = task.taskId + created_task_id[0] = task.task_id async def do_sampling() -> None: result = CreateMessageResult( role="assistant", content=TextContent(type="text", text="Sampled response"), model="test-model", - stopReason="endTurn", + stop_reason="endTurn", ) - await store.store_result(task.taskId, result) - await store.update_task(task.taskId, status="completed") + await store.store_result(task.task_id, result) + await store.update_task(task.task_id, status="completed") sampling_completed.set() assert background_tg[0] is not None @@ -388,27 +384,27 @@ async def do_sampling() -> None: return CreateTaskResult(task=task) async def get_task_handler( - context: RequestContext[ClientSession, None], + context: RequestContext[ClientSession], params: GetTaskRequestParams, ) -> GetTaskResult | ErrorData: - task = await store.get_task(params.taskId) - assert task is not None, f"Test setup error: task {params.taskId} should exist" + task = await store.get_task(params.task_id) + assert task is not None, f"Test setup error: task {params.task_id} should exist" return GetTaskResult( - taskId=task.taskId, + task_id=task.task_id, status=task.status, - statusMessage=task.statusMessage, - createdAt=task.createdAt, - lastUpdatedAt=task.lastUpdatedAt, + status_message=task.status_message, + created_at=task.created_at, + last_updated_at=task.last_updated_at, ttl=task.ttl, - pollInterval=task.pollInterval, + poll_interval=task.poll_interval, ) async def get_task_result_handler( - context: RequestContext[ClientSession, None], + context: RequestContext[ClientSession], params: GetTaskPayloadRequestParams, ) -> GetTaskPayloadResult | ErrorData: - result = await store.get_result(params.taskId) - assert result is not None, f"Test setup error: result for {params.taskId} should exist" + result = await store.get_result(params.task_id) + assert result is not None, f"Test setup error: result for {params.task_id} should exist" assert isinstance(result, CreateMessageResult) return GetTaskPayloadResult(**result.model_dump()) @@ -439,7 +435,7 @@ async def run_client() -> None: typed_request = CreateMessageRequest( params=CreateMessageRequestParams( messages=[SamplingMessage(role="user", content=TextContent(type="text", text="Hello"))], - maxTokens=100, + max_tokens=100, task=TaskMetadata(ttl=60000), ) ) @@ -448,47 +444,47 @@ async def run_client() -> None: id="req-sampling", **typed_request.model_dump(by_alias=True), ) - await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request))) + await client_streams.server_send.send(SessionMessage(request)) # Step 2: Client responds with CreateTaskResult response_msg = await client_streams.server_receive.receive() - response = response_msg.message.root + response = response_msg.message assert isinstance(response, types.JSONRPCResponse) task_result = CreateTaskResult.model_validate(response.result) - task_id = task_result.task.taskId + task_id = task_result.task.task_id assert task_id == created_task_id[0] # Step 3: Wait for background sampling await sampling_completed.wait() # Step 4: Server polls task status - typed_poll = GetTaskRequest(params=GetTaskRequestParams(taskId=task_id)) + typed_poll = GetTaskRequest(params=GetTaskRequestParams(task_id=task_id)) poll_request = types.JSONRPCRequest( jsonrpc="2.0", id="req-poll", **typed_poll.model_dump(by_alias=True), ) - await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(poll_request))) + await client_streams.server_send.send(SessionMessage(poll_request)) poll_response_msg = await client_streams.server_receive.receive() - poll_response = poll_response_msg.message.root + poll_response = poll_response_msg.message assert isinstance(poll_response, types.JSONRPCResponse) status = GetTaskResult.model_validate(poll_response.result) assert status.status == "completed" # Step 5: Server gets result - typed_result_req = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId=task_id)) + typed_result_req = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(task_id=task_id)) result_request = types.JSONRPCRequest( jsonrpc="2.0", id="req-result", **typed_result_req.model_dump(by_alias=True), ) - await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(result_request))) + await client_streams.server_send.send(SessionMessage(result_request)) result_response_msg = await client_streams.server_receive.receive() - result_response = result_response_msg.message.root + result_response = result_response_msg.message assert isinstance(result_response, types.JSONRPCResponse) assert isinstance(result_response.result, dict) @@ -509,18 +505,18 @@ async def test_client_task_augmented_elicitation(client_streams: ClientTestStrea background_tg: list[TaskGroup | None] = [None] async def task_augmented_elicitation_callback( - context: RequestContext[ClientSession, None], + context: RequestContext[ClientSession], params: ElicitRequestParams, task_metadata: TaskMetadata, ) -> CreateTaskResult | ErrorData: task = await store.create_task(task_metadata) - created_task_id[0] = task.taskId + created_task_id[0] = task.task_id async def do_elicitation() -> None: # Simulate user providing elicitation response result = ElicitResult(action="accept", content={"name": "Test User"}) - await store.store_result(task.taskId, result) - await store.update_task(task.taskId, status="completed") + await store.store_result(task.task_id, result) + await store.update_task(task.task_id, status="completed") elicitation_completed.set() assert background_tg[0] is not None @@ -528,27 +524,27 @@ async def do_elicitation() -> None: return CreateTaskResult(task=task) async def get_task_handler( - context: RequestContext[ClientSession, None], + context: RequestContext[ClientSession], params: GetTaskRequestParams, ) -> GetTaskResult | ErrorData: - task = await store.get_task(params.taskId) - assert task is not None, f"Test setup error: task {params.taskId} should exist" + task = await store.get_task(params.task_id) + assert task is not None, f"Test setup error: task {params.task_id} should exist" return GetTaskResult( - taskId=task.taskId, + task_id=task.task_id, status=task.status, - statusMessage=task.statusMessage, - createdAt=task.createdAt, - lastUpdatedAt=task.lastUpdatedAt, + status_message=task.status_message, + created_at=task.created_at, + last_updated_at=task.last_updated_at, ttl=task.ttl, - pollInterval=task.pollInterval, + poll_interval=task.poll_interval, ) async def get_task_result_handler( - context: RequestContext[ClientSession, None], + context: RequestContext[ClientSession], params: GetTaskPayloadRequestParams, ) -> GetTaskPayloadResult | ErrorData: - result = await store.get_result(params.taskId) - assert result is not None, f"Test setup error: result for {params.taskId} should exist" + result = await store.get_result(params.task_id) + assert result is not None, f"Test setup error: result for {params.task_id} should exist" assert isinstance(result, ElicitResult) return GetTaskPayloadResult(**result.model_dump()) @@ -579,7 +575,7 @@ async def run_client() -> None: typed_request = ElicitRequest( params=ElicitRequestFormParams( message="What is your name?", - requestedSchema={"type": "object", "properties": {"name": {"type": "string"}}}, + requested_schema={"type": "object", "properties": {"name": {"type": "string"}}}, task=TaskMetadata(ttl=60000), ) ) @@ -588,47 +584,47 @@ async def run_client() -> None: id="req-elicit", **typed_request.model_dump(by_alias=True), ) - await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request))) + await client_streams.server_send.send(SessionMessage(request)) # Step 2: Client responds with CreateTaskResult response_msg = await client_streams.server_receive.receive() - response = response_msg.message.root + response = response_msg.message assert isinstance(response, types.JSONRPCResponse) task_result = CreateTaskResult.model_validate(response.result) - task_id = task_result.task.taskId + task_id = task_result.task.task_id assert task_id == created_task_id[0] # Step 3: Wait for background elicitation await elicitation_completed.wait() # Step 4: Server polls task status - typed_poll = GetTaskRequest(params=GetTaskRequestParams(taskId=task_id)) + typed_poll = GetTaskRequest(params=GetTaskRequestParams(task_id=task_id)) poll_request = types.JSONRPCRequest( jsonrpc="2.0", id="req-poll", **typed_poll.model_dump(by_alias=True), ) - await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(poll_request))) + await client_streams.server_send.send(SessionMessage(poll_request)) poll_response_msg = await client_streams.server_receive.receive() - poll_response = poll_response_msg.message.root + poll_response = poll_response_msg.message assert isinstance(poll_response, types.JSONRPCResponse) status = GetTaskResult.model_validate(poll_response.result) assert status.status == "completed" # Step 5: Server gets result - typed_result_req = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId=task_id)) + typed_result_req = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(task_id=task_id)) result_request = types.JSONRPCRequest( jsonrpc="2.0", id="req-result", **typed_result_req.model_dump(by_alias=True), ) - await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(result_request))) + await client_streams.server_send.send(SessionMessage(result_request)) result_response_msg = await client_streams.server_receive.receive() - result_response = result_response_msg.message.root + result_response = result_response_msg.message assert isinstance(result_response, types.JSONRPCResponse) # Verify the elicitation result @@ -661,16 +657,16 @@ async def run_client() -> None: tg.start_soon(run_client) await client_ready.wait() - typed_request = GetTaskRequest(params=GetTaskRequestParams(taskId="nonexistent")) + typed_request = GetTaskRequest(params=GetTaskRequestParams(task_id="nonexistent")) request = types.JSONRPCRequest( jsonrpc="2.0", id="req-unhandled", **typed_request.model_dump(by_alias=True), ) - await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request))) + await client_streams.server_send.send(SessionMessage(request)) response_msg = await client_streams.server_receive.receive() - response = response_msg.message.root + response = response_msg.message assert isinstance(response, types.JSONRPCError) assert ( "not supported" in response.error.message.lower() @@ -700,16 +696,16 @@ async def run_client() -> None: tg.start_soon(run_client) await client_ready.wait() - typed_request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId="nonexistent")) + typed_request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(task_id="nonexistent")) request = types.JSONRPCRequest( jsonrpc="2.0", id="req-result", **typed_request.model_dump(by_alias=True), ) - await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request))) + await client_streams.server_send.send(SessionMessage(request)) response_msg = await client_streams.server_receive.receive() - response = response_msg.message.root + response = response_msg.message assert isinstance(response, types.JSONRPCError) assert "not supported" in response.error.message.lower() @@ -742,10 +738,10 @@ async def run_client() -> None: id="req-list", **typed_request.model_dump(by_alias=True), ) - await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request))) + await client_streams.server_send.send(SessionMessage(request)) response_msg = await client_streams.server_receive.receive() - response = response_msg.message.root + response = response_msg.message assert isinstance(response, types.JSONRPCError) assert "not supported" in response.error.message.lower() @@ -772,16 +768,16 @@ async def run_client() -> None: tg.start_soon(run_client) await client_ready.wait() - typed_request = CancelTaskRequest(params=CancelTaskRequestParams(taskId="nonexistent")) + typed_request = CancelTaskRequest(params=CancelTaskRequestParams(task_id="nonexistent")) request = types.JSONRPCRequest( jsonrpc="2.0", id="req-cancel", **typed_request.model_dump(by_alias=True), ) - await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request))) + await client_streams.server_send.send(SessionMessage(request)) response_msg = await client_streams.server_receive.receive() - response = response_msg.message.root + response = response_msg.message assert isinstance(response, types.JSONRPCError) assert "not supported" in response.error.message.lower() @@ -813,7 +809,7 @@ async def run_client() -> None: typed_request = CreateMessageRequest( params=CreateMessageRequestParams( messages=[SamplingMessage(role="user", content=TextContent(type="text", text="Hello"))], - maxTokens=100, + max_tokens=100, task=TaskMetadata(ttl=60000), ) ) @@ -822,10 +818,10 @@ async def run_client() -> None: id="req-sampling", **typed_request.model_dump(by_alias=True), ) - await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request))) + await client_streams.server_send.send(SessionMessage(request)) response_msg = await client_streams.server_receive.receive() - response = response_msg.message.root + response = response_msg.message assert isinstance(response, types.JSONRPCError) assert "not supported" in response.error.message.lower() @@ -859,7 +855,7 @@ async def run_client() -> None: typed_request = ElicitRequest( params=ElicitRequestFormParams( message="What is your name?", - requestedSchema={"type": "object", "properties": {"name": {"type": "string"}}}, + requested_schema={"type": "object", "properties": {"name": {"type": "string"}}}, task=TaskMetadata(ttl=60000), ) ) @@ -868,10 +864,10 @@ async def run_client() -> None: id="req-elicit", **typed_request.model_dump(by_alias=True), ) - await client_streams.server_send.send(SessionMessage(types.JSONRPCMessage(request))) + await client_streams.server_send.send(SessionMessage(request)) response_msg = await client_streams.server_receive.receive() - response = response_msg.message.root + response = response_msg.message assert isinstance(response, types.JSONRPCError) assert "not supported" in response.error.message.lower() diff --git a/tests/experimental/tasks/client/test_poll_task.py b/tests/experimental/tasks/client/test_poll_task.py index 8275dc668..5e3158d95 100644 --- a/tests/experimental/tasks/client/test_poll_task.py +++ b/tests/experimental/tasks/client/test_poll_task.py @@ -20,13 +20,13 @@ def make_task_result( """Create GetTaskResult with sensible defaults.""" now = datetime.now(timezone.utc) return GetTaskResult( - taskId=task_id, + task_id=task_id, status=status, - statusMessage=status_message, - createdAt=now, - lastUpdatedAt=now, + status_message=status_message, + created_at=now, + last_updated_at=now, ttl=60000, - pollInterval=poll_interval, + poll_interval=poll_interval, ) @@ -117,5 +117,5 @@ async def mock_get_task(task_id: str) -> GetTaskResult: assert len(results) == 1 assert results[0].status == "completed" - assert results[0].statusMessage == "All done!" - assert results[0].taskId == "test-task" + assert results[0].status_message == "All done!" + assert results[0].task_id == "test-task" diff --git a/tests/experimental/tasks/client/test_tasks.py b/tests/experimental/tasks/client/test_tasks.py index 24c8891de..613c794eb 100644 --- a/tests/experimental/tasks/client/test_tasks.py +++ b/tests/experimental/tasks/client/test_tasks.py @@ -1,44 +1,38 @@ """Tests for the experimental client task methods (session.experimental).""" +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from dataclasses import dataclass, field -from typing import Any import anyio import pytest from anyio import Event from anyio.abc import TaskGroup -from mcp.client.session import ClientSession -from mcp.server import Server -from mcp.server.lowlevel import NotificationOptions -from mcp.server.models import InitializationOptions -from mcp.server.session import ServerSession +from mcp import Client +from mcp.server import Server, ServerRequestContext from mcp.shared.experimental.tasks.helpers import task_execution from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore -from mcp.shared.message import SessionMessage -from mcp.shared.session import RequestResponder from mcp.types import ( CallToolRequest, CallToolRequestParams, CallToolResult, - CancelTaskRequest, + CancelTaskRequestParams, CancelTaskResult, - ClientRequest, - ClientResult, CreateTaskResult, - GetTaskPayloadRequest, + GetTaskPayloadRequestParams, GetTaskPayloadResult, - GetTaskRequest, + GetTaskRequestParams, GetTaskResult, - ListTasksRequest, ListTasksResult, - ServerNotification, - ServerRequest, + ListToolsResult, + PaginatedRequestParams, TaskMetadata, TextContent, - Tool, ) +pytestmark = pytest.mark.anyio + @dataclass class AppContext: @@ -49,334 +43,198 @@ class AppContext: task_done_events: dict[str, Event] = field(default_factory=lambda: {}) -@pytest.mark.anyio -async def test_session_experimental_get_task() -> None: - """Test session.experimental.get_task() method.""" - # Note: We bypass the normal lifespan mechanism - server: Server[AppContext, Any] = Server("test-server") # type: ignore[assignment] - store = InMemoryTaskStore() +async def _handle_list_tools( + ctx: ServerRequestContext[AppContext], params: PaginatedRequestParams | None +) -> ListToolsResult: + raise NotImplementedError - @server.list_tools() - async def list_tools(): - return [Tool(name="test_tool", description="Test", inputSchema={"type": "object"})] - @server.call_tool() - async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent] | CreateTaskResult: - ctx = server.request_context - app = ctx.lifespan_context - if ctx.experimental.is_task: - task_metadata = ctx.experimental.task_metadata - assert task_metadata is not None - task = await app.store.create_task(task_metadata) +async def _handle_call_tool_with_done_event( + ctx: ServerRequestContext[AppContext], params: CallToolRequestParams, *, result_text: str = "Done" +) -> CallToolResult | CreateTaskResult: + app = ctx.lifespan_context + if ctx.experimental.is_task: + task_metadata = ctx.experimental.task_metadata + assert task_metadata is not None + task = await app.store.create_task(task_metadata) - done_event = Event() - app.task_done_events[task.taskId] = done_event + done_event = Event() + app.task_done_events[task.task_id] = done_event - async def do_work(): - async with task_execution(task.taskId, app.store) as task_ctx: - await task_ctx.complete(CallToolResult(content=[TextContent(type="text", text="Done")])) - done_event.set() + async def do_work() -> None: + async with task_execution(task.task_id, app.store) as task_ctx: + await task_ctx.complete(CallToolResult(content=[TextContent(type="text", text=result_text)])) + done_event.set() - app.task_group.start_soon(do_work) - return CreateTaskResult(task=task) + app.task_group.start_soon(do_work) + return CreateTaskResult(task=task) + + raise NotImplementedError + + +def _make_lifespan(store: InMemoryTaskStore, task_done_events: dict[str, Event]): + @asynccontextmanager + async def app_lifespan(server: Server[AppContext]) -> AsyncIterator[AppContext]: + async with anyio.create_task_group() as tg: + yield AppContext(task_group=tg, store=store, task_done_events=task_done_events) + + return app_lifespan - raise NotImplementedError - @server.experimental.get_task() - async def handle_get_task(request: GetTaskRequest) -> GetTaskResult: - app = server.request_context.lifespan_context - task = await app.store.get_task(request.params.taskId) - assert task is not None, f"Test setup error: task {request.params.taskId} should exist" +async def test_session_experimental_get_task() -> None: + """Test session.experimental.get_task() method.""" + store = InMemoryTaskStore() + task_done_events: dict[str, Event] = {} + + async def handle_get_task(ctx: ServerRequestContext[AppContext], params: GetTaskRequestParams) -> GetTaskResult: + app = ctx.lifespan_context + task = await app.store.get_task(params.task_id) + assert task is not None, f"Test setup error: task {params.task_id} should exist" return GetTaskResult( - taskId=task.taskId, + task_id=task.task_id, status=task.status, - statusMessage=task.statusMessage, - createdAt=task.createdAt, - lastUpdatedAt=task.lastUpdatedAt, + status_message=task.status_message, + created_at=task.created_at, + last_updated_at=task.last_updated_at, ttl=task.ttl, - pollInterval=task.pollInterval, + poll_interval=task.poll_interval, ) - # Set up streams - server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) - client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) - - async def message_handler( - message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, - ) -> None: ... # pragma: no branch - - async def run_server(app_context: AppContext): - async with ServerSession( - client_to_server_receive, - server_to_client_send, - InitializationOptions( - server_name="test-server", - server_version="1.0.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), + server: Server[AppContext] = Server( + "test-server", + lifespan=_make_lifespan(store, task_done_events), + on_list_tools=_handle_list_tools, + on_call_tool=_handle_call_tool_with_done_event, + ) + server.experimental.enable_tasks(on_get_task=handle_get_task) + + async with Client(server) as client: + # Create a task + create_result = await client.session.send_request( + CallToolRequest( + params=CallToolRequestParams( + name="test_tool", + arguments={}, + task=TaskMetadata(ttl=60000), + ) ), - ) as server_session: - async for message in server_session.incoming_messages: - await server._handle_message(message, server_session, app_context, raise_exceptions=False) - - async with anyio.create_task_group() as tg: - app_context = AppContext(task_group=tg, store=store) - tg.start_soon(run_server, app_context) - - async with ClientSession( - server_to_client_receive, - client_to_server_send, - message_handler=message_handler, - ) as client_session: - await client_session.initialize() - - # Create a task - create_result = await client_session.send_request( - ClientRequest( - CallToolRequest( - params=CallToolRequestParams( - name="test_tool", - arguments={}, - task=TaskMetadata(ttl=60000), - ) - ) - ), - CreateTaskResult, - ) - task_id = create_result.task.taskId - - # Wait for task to complete - await app_context.task_done_events[task_id].wait() + CreateTaskResult, + ) + task_id = create_result.task.task_id - # Use session.experimental to get task status - task_status = await client_session.experimental.get_task(task_id) + # Wait for task to complete + await task_done_events[task_id].wait() - assert task_status.taskId == task_id - assert task_status.status == "completed" + # Use session.experimental to get task status + task_status = await client.session.experimental.get_task(task_id) - tg.cancel_scope.cancel() + assert task_status.task_id == task_id + assert task_status.status == "completed" -@pytest.mark.anyio async def test_session_experimental_get_task_result() -> None: """Test session.experimental.get_task_result() method.""" - server: Server[AppContext, Any] = Server("test-server") # type: ignore[assignment] store = InMemoryTaskStore() + task_done_events: dict[str, Event] = {} - @server.list_tools() - async def list_tools(): - return [Tool(name="test_tool", description="Test", inputSchema={"type": "object"})] - - @server.call_tool() - async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent] | CreateTaskResult: - ctx = server.request_context - app = ctx.lifespan_context - if ctx.experimental.is_task: - task_metadata = ctx.experimental.task_metadata - assert task_metadata is not None - task = await app.store.create_task(task_metadata) - - done_event = Event() - app.task_done_events[task.taskId] = done_event - - async def do_work(): - async with task_execution(task.taskId, app.store) as task_ctx: - await task_ctx.complete( - CallToolResult(content=[TextContent(type="text", text="Task result content")]) - ) - done_event.set() - - app.task_group.start_soon(do_work) - return CreateTaskResult(task=task) - - raise NotImplementedError + async def handle_call_tool( + ctx: ServerRequestContext[AppContext], params: CallToolRequestParams + ) -> CallToolResult | CreateTaskResult: + return await _handle_call_tool_with_done_event(ctx, params, result_text="Task result content") - @server.experimental.get_task_result() async def handle_get_task_result( - request: GetTaskPayloadRequest, + ctx: ServerRequestContext[AppContext], params: GetTaskPayloadRequestParams ) -> GetTaskPayloadResult: - app = server.request_context.lifespan_context - result = await app.store.get_result(request.params.taskId) - assert result is not None, f"Test setup error: result for {request.params.taskId} should exist" + app = ctx.lifespan_context + result = await app.store.get_result(params.task_id) + assert result is not None, f"Test setup error: result for {params.task_id} should exist" assert isinstance(result, CallToolResult) return GetTaskPayloadResult(**result.model_dump()) - # Set up streams - server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) - client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) - - async def message_handler( - message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, - ) -> None: ... # pragma: no branch - - async def run_server(app_context: AppContext): - async with ServerSession( - client_to_server_receive, - server_to_client_send, - InitializationOptions( - server_name="test-server", - server_version="1.0.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), + server: Server[AppContext] = Server( + "test-server", + lifespan=_make_lifespan(store, task_done_events), + on_list_tools=_handle_list_tools, + on_call_tool=handle_call_tool, + ) + server.experimental.enable_tasks(on_task_result=handle_get_task_result) + + async with Client(server) as client: + # Create a task + create_result = await client.session.send_request( + CallToolRequest( + params=CallToolRequestParams( + name="test_tool", + arguments={}, + task=TaskMetadata(ttl=60000), + ) ), - ) as server_session: - async for message in server_session.incoming_messages: - await server._handle_message(message, server_session, app_context, raise_exceptions=False) - - async with anyio.create_task_group() as tg: - app_context = AppContext(task_group=tg, store=store) - tg.start_soon(run_server, app_context) - - async with ClientSession( - server_to_client_receive, - client_to_server_send, - message_handler=message_handler, - ) as client_session: - await client_session.initialize() - - # Create a task - create_result = await client_session.send_request( - ClientRequest( - CallToolRequest( - params=CallToolRequestParams( - name="test_tool", - arguments={}, - task=TaskMetadata(ttl=60000), - ) - ) - ), - CreateTaskResult, - ) - task_id = create_result.task.taskId - - # Wait for task to complete - await app_context.task_done_events[task_id].wait() + CreateTaskResult, + ) + task_id = create_result.task.task_id - # Use TaskClient to get task result - task_result = await client_session.experimental.get_task_result(task_id, CallToolResult) + # Wait for task to complete + await task_done_events[task_id].wait() - assert len(task_result.content) == 1 - content = task_result.content[0] - assert isinstance(content, TextContent) - assert content.text == "Task result content" + # Use TaskClient to get task result + task_result = await client.session.experimental.get_task_result(task_id, CallToolResult) - tg.cancel_scope.cancel() + assert len(task_result.content) == 1 + content = task_result.content[0] + assert isinstance(content, TextContent) + assert content.text == "Task result content" -@pytest.mark.anyio async def test_session_experimental_list_tasks() -> None: """Test TaskClient.list_tasks() method.""" - server: Server[AppContext, Any] = Server("test-server") # type: ignore[assignment] store = InMemoryTaskStore() + task_done_events: dict[str, Event] = {} - @server.list_tools() - async def list_tools(): - return [Tool(name="test_tool", description="Test", inputSchema={"type": "object"})] - - @server.call_tool() - async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent] | CreateTaskResult: - ctx = server.request_context + async def handle_list_tasks( + ctx: ServerRequestContext[AppContext], params: PaginatedRequestParams | None + ) -> ListTasksResult: app = ctx.lifespan_context - if ctx.experimental.is_task: - task_metadata = ctx.experimental.task_metadata - assert task_metadata is not None - task = await app.store.create_task(task_metadata) - - done_event = Event() - app.task_done_events[task.taskId] = done_event - - async def do_work(): - async with task_execution(task.taskId, app.store) as task_ctx: - await task_ctx.complete(CallToolResult(content=[TextContent(type="text", text="Done")])) - done_event.set() - - app.task_group.start_soon(do_work) - return CreateTaskResult(task=task) - - raise NotImplementedError - - @server.experimental.list_tasks() - async def handle_list_tasks(request: ListTasksRequest) -> ListTasksResult: - app = server.request_context.lifespan_context - tasks_list, next_cursor = await app.store.list_tasks(cursor=request.params.cursor if request.params else None) - return ListTasksResult(tasks=tasks_list, nextCursor=next_cursor) - - # Set up streams - server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) - client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) - - async def message_handler( - message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, - ) -> None: ... # pragma: no branch - - async def run_server(app_context: AppContext): - async with ServerSession( - client_to_server_receive, - server_to_client_send, - InitializationOptions( - server_name="test-server", - server_version="1.0.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, + cursor = params.cursor if params else None + tasks_list, next_cursor = await app.store.list_tasks(cursor=cursor) + return ListTasksResult(tasks=tasks_list, next_cursor=next_cursor) + + server: Server[AppContext] = Server( + "test-server", + lifespan=_make_lifespan(store, task_done_events), + on_list_tools=_handle_list_tools, + on_call_tool=_handle_call_tool_with_done_event, + ) + server.experimental.enable_tasks(on_list_tasks=handle_list_tasks) + + async with Client(server) as client: + # Create two tasks + for _ in range(2): + create_result = await client.session.send_request( + CallToolRequest( + params=CallToolRequestParams( + name="test_tool", + arguments={}, + task=TaskMetadata(ttl=60000), + ) ), - ), - ) as server_session: - async for message in server_session.incoming_messages: - await server._handle_message(message, server_session, app_context, raise_exceptions=False) - - async with anyio.create_task_group() as tg: - app_context = AppContext(task_group=tg, store=store) - tg.start_soon(run_server, app_context) - - async with ClientSession( - server_to_client_receive, - client_to_server_send, - message_handler=message_handler, - ) as client_session: - await client_session.initialize() - - # Create two tasks - for _ in range(2): - create_result = await client_session.send_request( - ClientRequest( - CallToolRequest( - params=CallToolRequestParams( - name="test_tool", - arguments={}, - task=TaskMetadata(ttl=60000), - ) - ) - ), - CreateTaskResult, - ) - await app_context.task_done_events[create_result.task.taskId].wait() - - # Use TaskClient to list tasks - list_result = await client_session.experimental.list_tasks() + CreateTaskResult, + ) + await task_done_events[create_result.task.task_id].wait() - assert len(list_result.tasks) == 2 + # Use TaskClient to list tasks + list_result = await client.session.experimental.list_tasks() - tg.cancel_scope.cancel() + assert len(list_result.tasks) == 2 -@pytest.mark.anyio async def test_session_experimental_cancel_task() -> None: """Test TaskClient.cancel_task() method.""" - server: Server[AppContext, Any] = Server("test-server") # type: ignore[assignment] store = InMemoryTaskStore() + task_done_events: dict[str, Event] = {} - @server.list_tools() - async def list_tools(): - return [Tool(name="test_tool", description="Test", inputSchema={"type": "object"})] - - @server.call_tool() - async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent] | CreateTaskResult: - ctx = server.request_context + async def handle_call_tool_no_work( + ctx: ServerRequestContext[AppContext], params: CallToolRequestParams + ) -> CallToolResult | CreateTaskResult: app = ctx.lifespan_context if ctx.experimental.is_task: task_metadata = ctx.experimental.task_metadata @@ -384,100 +242,68 @@ async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[TextCon task = await app.store.create_task(task_metadata) # Don't start any work - task stays in "working" status return CreateTaskResult(task=task) - raise NotImplementedError - @server.experimental.get_task() - async def handle_get_task(request: GetTaskRequest) -> GetTaskResult: - app = server.request_context.lifespan_context - task = await app.store.get_task(request.params.taskId) - assert task is not None, f"Test setup error: task {request.params.taskId} should exist" + async def handle_get_task(ctx: ServerRequestContext[AppContext], params: GetTaskRequestParams) -> GetTaskResult: + app = ctx.lifespan_context + task = await app.store.get_task(params.task_id) + assert task is not None, f"Test setup error: task {params.task_id} should exist" return GetTaskResult( - taskId=task.taskId, + task_id=task.task_id, status=task.status, - statusMessage=task.statusMessage, - createdAt=task.createdAt, - lastUpdatedAt=task.lastUpdatedAt, + status_message=task.status_message, + created_at=task.created_at, + last_updated_at=task.last_updated_at, ttl=task.ttl, - pollInterval=task.pollInterval, + poll_interval=task.poll_interval, ) - @server.experimental.cancel_task() - async def handle_cancel_task(request: CancelTaskRequest) -> CancelTaskResult: - app = server.request_context.lifespan_context - task = await app.store.get_task(request.params.taskId) - assert task is not None, f"Test setup error: task {request.params.taskId} should exist" - await app.store.update_task(request.params.taskId, status="cancelled") - # CancelTaskResult extends Task, so we need to return the updated task info - updated_task = await app.store.get_task(request.params.taskId) + async def handle_cancel_task( + ctx: ServerRequestContext[AppContext], params: CancelTaskRequestParams + ) -> CancelTaskResult: + app = ctx.lifespan_context + task = await app.store.get_task(params.task_id) + assert task is not None, f"Test setup error: task {params.task_id} should exist" + await app.store.update_task(params.task_id, status="cancelled") + updated_task = await app.store.get_task(params.task_id) assert updated_task is not None return CancelTaskResult( - taskId=updated_task.taskId, + task_id=updated_task.task_id, status=updated_task.status, - createdAt=updated_task.createdAt, - lastUpdatedAt=updated_task.lastUpdatedAt, + created_at=updated_task.created_at, + last_updated_at=updated_task.last_updated_at, ttl=updated_task.ttl, ) - # Set up streams - server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) - client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) - - async def message_handler( - message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, - ) -> None: ... # pragma: no branch - - async def run_server(app_context: AppContext): - async with ServerSession( - client_to_server_receive, - server_to_client_send, - InitializationOptions( - server_name="test-server", - server_version="1.0.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), + server: Server[AppContext] = Server( + "test-server", + lifespan=_make_lifespan(store, task_done_events), + on_list_tools=_handle_list_tools, + on_call_tool=handle_call_tool_no_work, + ) + server.experimental.enable_tasks(on_get_task=handle_get_task, on_cancel_task=handle_cancel_task) + + async with Client(server) as client: + # Create a task (but don't complete it) + create_result = await client.session.send_request( + CallToolRequest( + params=CallToolRequestParams( + name="test_tool", + arguments={}, + task=TaskMetadata(ttl=60000), + ) ), - ) as server_session: - async for message in server_session.incoming_messages: - await server._handle_message(message, server_session, app_context, raise_exceptions=False) - - async with anyio.create_task_group() as tg: - app_context = AppContext(task_group=tg, store=store) - tg.start_soon(run_server, app_context) - - async with ClientSession( - server_to_client_receive, - client_to_server_send, - message_handler=message_handler, - ) as client_session: - await client_session.initialize() - - # Create a task (but don't complete it) - create_result = await client_session.send_request( - ClientRequest( - CallToolRequest( - params=CallToolRequestParams( - name="test_tool", - arguments={}, - task=TaskMetadata(ttl=60000), - ) - ) - ), - CreateTaskResult, - ) - task_id = create_result.task.taskId - - # Verify task is working - status_before = await client_session.experimental.get_task(task_id) - assert status_before.status == "working" + CreateTaskResult, + ) + task_id = create_result.task.task_id - # Cancel the task - await client_session.experimental.cancel_task(task_id) + # Verify task is working + status_before = await client.session.experimental.get_task(task_id) + assert status_before.status == "working" - # Verify task is cancelled - status_after = await client_session.experimental.get_task(task_id) - assert status_after.status == "cancelled" + # Cancel the task + await client.session.experimental.cancel_task(task_id) - tg.cancel_scope.cancel() + # Verify task is cancelled + status_after = await client.session.experimental.get_task(task_id) + assert status_after.status == "cancelled" diff --git a/tests/experimental/tasks/server/test_context.py b/tests/experimental/tasks/server/test_context.py index 2f09ff154..a0f1a190d 100644 --- a/tests/experimental/tasks/server/test_context.py +++ b/tests/experimental/tasks/server/test_context.py @@ -15,8 +15,8 @@ async def test_task_context_properties() -> None: task = await store.create_task(metadata=TaskMetadata(ttl=60000)) ctx = TaskContext(task, store) - assert ctx.task_id == task.taskId - assert ctx.task.taskId == task.taskId + assert ctx.task_id == task.task_id + assert ctx.task.task_id == task.task_id assert ctx.task.status == "working" assert ctx.is_cancelled is False @@ -33,9 +33,9 @@ async def test_task_context_update_status() -> None: await ctx.update_status("Processing step 1...") # Check status message was updated - updated = await store.get_task(task.taskId) + updated = await store.get_task(task.task_id) assert updated is not None - assert updated.statusMessage == "Processing step 1..." + assert updated.status_message == "Processing step 1..." store.cleanup() @@ -51,12 +51,12 @@ async def test_task_context_complete() -> None: await ctx.complete(result) # Check task status - updated = await store.get_task(task.taskId) + updated = await store.get_task(task.task_id) assert updated is not None assert updated.status == "completed" # Check result is stored - stored_result = await store.get_result(task.taskId) + stored_result = await store.get_result(task.task_id) assert stored_result is not None store.cleanup() @@ -72,10 +72,10 @@ async def test_task_context_fail() -> None: await ctx.fail("Something went wrong!") # Check task status - updated = await store.get_task(task.taskId) + updated = await store.get_task(task.task_id) assert updated is not None assert updated.status == "failed" - assert updated.statusMessage == "Something went wrong!" + assert updated.status_message == "Something went wrong!" store.cleanup() @@ -101,13 +101,13 @@ def test_create_task_state_generates_id() -> None: task1 = create_task_state(TaskMetadata(ttl=60000)) task2 = create_task_state(TaskMetadata(ttl=60000)) - assert task1.taskId != task2.taskId + assert task1.task_id != task2.task_id def test_create_task_state_uses_provided_id() -> None: """create_task_state uses the provided task ID.""" task = create_task_state(TaskMetadata(ttl=60000), task_id="my-task-123") - assert task.taskId == "my-task-123" + assert task.task_id == "my-task-123" def test_create_task_state_null_ttl() -> None: @@ -119,7 +119,7 @@ def test_create_task_state_null_ttl() -> None: def test_create_task_state_has_created_at() -> None: """create_task_state sets createdAt timestamp.""" task = create_task_state(TaskMetadata(ttl=60000)) - assert task.createdAt is not None + assert task.created_at is not None @pytest.mark.anyio @@ -148,7 +148,7 @@ async def test_task_execution_auto_fails_on_exception() -> None: failed_task = await store.get_task("exec-fail-1") assert failed_task is not None assert failed_task.status == "failed" - assert "Oops!" in (failed_task.statusMessage or "") + assert "Oops!" in (failed_task.status_message or "") store.cleanup() diff --git a/tests/experimental/tasks/server/test_integration.py b/tests/experimental/tasks/server/test_integration.py index ba61dfcea..b5b79033d 100644 --- a/tests/experimental/tasks/server/test_integration.py +++ b/tests/experimental/tasks/server/test_integration.py @@ -8,47 +8,37 @@ 5. Client retrieves result with tasks/result """ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from dataclasses import dataclass, field -from typing import Any import anyio import pytest from anyio import Event from anyio.abc import TaskGroup -from mcp.client.session import ClientSession -from mcp.server import Server -from mcp.server.lowlevel import NotificationOptions -from mcp.server.models import InitializationOptions -from mcp.server.session import ServerSession +from mcp import Client +from mcp.server import Server, ServerRequestContext from mcp.shared.experimental.tasks.helpers import task_execution from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore -from mcp.shared.message import SessionMessage -from mcp.shared.session import RequestResponder from mcp.types import ( - TASK_REQUIRED, CallToolRequest, CallToolRequestParams, CallToolResult, - ClientRequest, - ClientResult, CreateTaskResult, - GetTaskPayloadRequest, GetTaskPayloadRequestParams, GetTaskPayloadResult, - GetTaskRequest, GetTaskRequestParams, GetTaskResult, - ListTasksRequest, ListTasksResult, - ServerNotification, - ServerRequest, + ListToolsResult, + PaginatedRequestParams, TaskMetadata, TextContent, - Tool, - ToolExecution, ) +pytestmark = pytest.mark.anyio + @dataclass class AppContext: @@ -56,218 +46,152 @@ class AppContext: task_group: TaskGroup store: InMemoryTaskStore - # Events to signal when tasks complete (for testing without sleeps) task_done_events: dict[str, Event] = field(default_factory=lambda: {}) -@pytest.mark.anyio +def _make_lifespan(store: InMemoryTaskStore, task_done_events: dict[str, Event]): + @asynccontextmanager + async def app_lifespan(server: Server[AppContext]) -> AsyncIterator[AppContext]: + async with anyio.create_task_group() as tg: + yield AppContext(task_group=tg, store=store, task_done_events=task_done_events) + + return app_lifespan + + async def test_task_lifecycle_with_task_execution() -> None: - """ - Test the complete task lifecycle using the task_execution pattern. - - This demonstrates the recommended way to implement task-augmented tools: - 1. Create task in store - 2. Spawn work using task_execution() context manager - 3. Return CreateTaskResult immediately - 4. Work executes in background, auto-fails on exception - """ - # Note: We bypass the normal lifespan mechanism and pass context directly to _handle_message - server: Server[AppContext, Any] = Server("test-tasks") # type: ignore[assignment] + """Test the complete task lifecycle using the task_execution pattern.""" store = InMemoryTaskStore() + task_done_events: dict[str, Event] = {} + + async def handle_list_tools( + ctx: ServerRequestContext[AppContext], params: PaginatedRequestParams | None + ) -> ListToolsResult: + raise NotImplementedError - @server.list_tools() - async def list_tools(): - return [ - Tool( - name="process_data", - description="Process data asynchronously", - inputSchema={ - "type": "object", - "properties": {"input": {"type": "string"}}, - }, - execution=ToolExecution(taskSupport=TASK_REQUIRED), - ) - ] - - @server.call_tool() - async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent] | CreateTaskResult: - ctx = server.request_context + async def handle_call_tool( + ctx: ServerRequestContext[AppContext], params: CallToolRequestParams + ) -> CallToolResult | CreateTaskResult: app = ctx.lifespan_context - if name == "process_data" and ctx.experimental.is_task: - # 1. Create task in store + if params.name == "process_data" and ctx.experimental.is_task: task_metadata = ctx.experimental.task_metadata assert task_metadata is not None task = await app.store.create_task(task_metadata) - # 2. Create event to signal completion (for testing) done_event = Event() - app.task_done_events[task.taskId] = done_event + app.task_done_events[task.task_id] = done_event - # 3. Define work function using task_execution for safety - async def do_work(): - async with task_execution(task.taskId, app.store) as task_ctx: + async def do_work() -> None: + async with task_execution(task.task_id, app.store) as task_ctx: await task_ctx.update_status("Processing input...") - # Simulate work - input_value = arguments.get("input", "") + input_value = (params.arguments or {}).get("input", "") result_text = f"Processed: {input_value.upper()}" await task_ctx.complete(CallToolResult(content=[TextContent(type="text", text=result_text)])) - # Signal completion done_event.set() - # 4. Spawn work in task group (from lifespan_context) app.task_group.start_soon(do_work) - - # 5. Return CreateTaskResult immediately return CreateTaskResult(task=task) raise NotImplementedError - # Register task query handlers (delegate to store) - @server.experimental.get_task() - async def handle_get_task(request: GetTaskRequest) -> GetTaskResult: - app = server.request_context.lifespan_context - task = await app.store.get_task(request.params.taskId) - assert task is not None, f"Test setup error: task {request.params.taskId} should exist" + async def handle_get_task(ctx: ServerRequestContext[AppContext], params: GetTaskRequestParams) -> GetTaskResult: + app = ctx.lifespan_context + task = await app.store.get_task(params.task_id) + assert task is not None, f"Test setup error: task {params.task_id} should exist" return GetTaskResult( - taskId=task.taskId, + task_id=task.task_id, status=task.status, - statusMessage=task.statusMessage, - createdAt=task.createdAt, - lastUpdatedAt=task.lastUpdatedAt, + status_message=task.status_message, + created_at=task.created_at, + last_updated_at=task.last_updated_at, ttl=task.ttl, - pollInterval=task.pollInterval, + poll_interval=task.poll_interval, ) - @server.experimental.get_task_result() async def handle_get_task_result( - request: GetTaskPayloadRequest, + ctx: ServerRequestContext[AppContext], params: GetTaskPayloadRequestParams ) -> GetTaskPayloadResult: - app = server.request_context.lifespan_context - result = await app.store.get_result(request.params.taskId) - assert result is not None, f"Test setup error: result for {request.params.taskId} should exist" + app = ctx.lifespan_context + result = await app.store.get_result(params.task_id) + assert result is not None, f"Test setup error: result for {params.task_id} should exist" assert isinstance(result, CallToolResult) - # Return as GetTaskPayloadResult (which accepts extra fields) return GetTaskPayloadResult(**result.model_dump()) - @server.experimental.list_tasks() - async def handle_list_tasks(request: ListTasksRequest) -> ListTasksResult: + async def handle_list_tasks( + ctx: ServerRequestContext[AppContext], params: PaginatedRequestParams | None + ) -> ListTasksResult: raise NotImplementedError - # Set up client-server communication - server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) - client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) - - async def message_handler( - message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, - ) -> None: ... # pragma: no cover - - async def run_server(app_context: AppContext): - async with ServerSession( - client_to_server_receive, - server_to_client_send, - InitializationOptions( - server_name="test-server", - server_version="1.0.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, + server: Server[AppContext] = Server( + "test-tasks", + lifespan=_make_lifespan(store, task_done_events), + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, + ) + server.experimental.enable_tasks( + on_get_task=handle_get_task, + on_task_result=handle_get_task_result, + on_list_tasks=handle_list_tasks, + ) + + async with Client(server) as client: + # Step 1: Send task-augmented tool call + create_result = await client.session.send_request( + CallToolRequest( + params=CallToolRequestParams( + name="process_data", + arguments={"input": "hello world"}, + task=TaskMetadata(ttl=60000), ), ), - ) as server_session: - async for message in server_session.incoming_messages: - await server._handle_message(message, server_session, app_context, raise_exceptions=False) - - async with anyio.create_task_group() as tg: - # Create app context with task group and store - app_context = AppContext(task_group=tg, store=store) - tg.start_soon(run_server, app_context) - - async with ClientSession( - server_to_client_receive, - client_to_server_send, - message_handler=message_handler, - ) as client_session: - await client_session.initialize() - - # === Step 1: Send task-augmented tool call === - create_result = await client_session.send_request( - ClientRequest( - CallToolRequest( - params=CallToolRequestParams( - name="process_data", - arguments={"input": "hello world"}, - task=TaskMetadata(ttl=60000), - ), - ) - ), - CreateTaskResult, - ) - - assert isinstance(create_result, CreateTaskResult) - assert create_result.task.status == "working" - task_id = create_result.task.taskId - - # === Step 2: Wait for task to complete === - await app_context.task_done_events[task_id].wait() + CreateTaskResult, + ) - task_status = await client_session.send_request( - ClientRequest(GetTaskRequest(params=GetTaskRequestParams(taskId=task_id))), - GetTaskResult, - ) + assert isinstance(create_result, CreateTaskResult) + assert create_result.task.status == "working" + task_id = create_result.task.task_id - assert task_status.taskId == task_id - assert task_status.status == "completed" + # Step 2: Wait for task to complete + await task_done_events[task_id].wait() - # === Step 3: Retrieve the actual result === - task_result = await client_session.send_request( - ClientRequest(GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId=task_id))), - CallToolResult, - ) + task_status = await client.session.experimental.get_task(task_id) + assert task_status.task_id == task_id + assert task_status.status == "completed" - assert len(task_result.content) == 1 - content = task_result.content[0] - assert isinstance(content, TextContent) - assert content.text == "Processed: HELLO WORLD" + # Step 3: Retrieve the actual result + task_result = await client.session.experimental.get_task_result(task_id, CallToolResult) - tg.cancel_scope.cancel() + assert len(task_result.content) == 1 + content = task_result.content[0] + assert isinstance(content, TextContent) + assert content.text == "Processed: HELLO WORLD" -@pytest.mark.anyio async def test_task_auto_fails_on_exception() -> None: """Test that task_execution automatically fails the task on unhandled exception.""" - # Note: We bypass the normal lifespan mechanism and pass context directly to _handle_message - server: Server[AppContext, Any] = Server("test-tasks-failure") # type: ignore[assignment] store = InMemoryTaskStore() + task_done_events: dict[str, Event] = {} + + async def handle_list_tools( + ctx: ServerRequestContext[AppContext], params: PaginatedRequestParams | None + ) -> ListToolsResult: + raise NotImplementedError - @server.list_tools() - async def list_tools(): - return [ - Tool( - name="failing_task", - description="A task that fails", - inputSchema={"type": "object", "properties": {}}, - ) - ] - - @server.call_tool() - async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent] | CreateTaskResult: - ctx = server.request_context + async def handle_call_tool( + ctx: ServerRequestContext[AppContext], params: CallToolRequestParams + ) -> CallToolResult | CreateTaskResult: app = ctx.lifespan_context - if name == "failing_task" and ctx.experimental.is_task: + if params.name == "failing_task" and ctx.experimental.is_task: task_metadata = ctx.experimental.task_metadata assert task_metadata is not None task = await app.store.create_task(task_metadata) - # Create event to signal completion (for testing) done_event = Event() - app.task_done_events[task.taskId] = done_event + app.task_done_events[task.task_id] = done_event - async def do_failing_work(): - async with task_execution(task.taskId, app.store) as task_ctx: + async def do_failing_work() -> None: + async with task_execution(task.task_id, app.store) as task_ctx: await task_ctx.update_status("About to fail...") raise RuntimeError("Something went wrong!") - # Note: complete() is never called, but task_execution - # will automatically call fail() due to the exception # This line is reached because task_execution suppresses the exception done_event.set() @@ -276,82 +200,48 @@ async def do_failing_work(): raise NotImplementedError - @server.experimental.get_task() - async def handle_get_task(request: GetTaskRequest) -> GetTaskResult: - app = server.request_context.lifespan_context - task = await app.store.get_task(request.params.taskId) - assert task is not None, f"Test setup error: task {request.params.taskId} should exist" + async def handle_get_task(ctx: ServerRequestContext[AppContext], params: GetTaskRequestParams) -> GetTaskResult: + app = ctx.lifespan_context + task = await app.store.get_task(params.task_id) + assert task is not None, f"Test setup error: task {params.task_id} should exist" return GetTaskResult( - taskId=task.taskId, + task_id=task.task_id, status=task.status, - statusMessage=task.statusMessage, - createdAt=task.createdAt, - lastUpdatedAt=task.lastUpdatedAt, + status_message=task.status_message, + created_at=task.created_at, + last_updated_at=task.last_updated_at, ttl=task.ttl, - pollInterval=task.pollInterval, + poll_interval=task.poll_interval, ) - # Set up streams - server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) - client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) - - async def message_handler( - message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, - ) -> None: ... # pragma: no cover - - async def run_server(app_context: AppContext): - async with ServerSession( - client_to_server_receive, - server_to_client_send, - InitializationOptions( - server_name="test-server", - server_version="1.0.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, + server: Server[AppContext] = Server( + "test-tasks-failure", + lifespan=_make_lifespan(store, task_done_events), + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, + ) + server.experimental.enable_tasks(on_get_task=handle_get_task) + + async with Client(server) as client: + # Send task request + create_result = await client.session.send_request( + CallToolRequest( + params=CallToolRequestParams( + name="failing_task", + arguments={}, + task=TaskMetadata(ttl=60000), ), ), - ) as server_session: - async for message in server_session.incoming_messages: - await server._handle_message(message, server_session, app_context, raise_exceptions=False) - - async with anyio.create_task_group() as tg: - app_context = AppContext(task_group=tg, store=store) - tg.start_soon(run_server, app_context) - - async with ClientSession( - server_to_client_receive, - client_to_server_send, - message_handler=message_handler, - ) as client_session: - await client_session.initialize() - - # Send task request - create_result = await client_session.send_request( - ClientRequest( - CallToolRequest( - params=CallToolRequestParams( - name="failing_task", - arguments={}, - task=TaskMetadata(ttl=60000), - ), - ) - ), - CreateTaskResult, - ) - - task_id = create_result.task.taskId + CreateTaskResult, + ) - # Wait for task to complete (even though it fails) - await app_context.task_done_events[task_id].wait() + task_id = create_result.task.task_id - # Check that task was auto-failed - task_status = await client_session.send_request( - ClientRequest(GetTaskRequest(params=GetTaskRequestParams(taskId=task_id))), - GetTaskResult, - ) + # Wait for task to complete (even though it fails) + await task_done_events[task_id].wait() - assert task_status.status == "failed" - assert task_status.statusMessage == "Something went wrong!" + # Check that task was auto-failed + task_status = await client.session.experimental.get_task(task_id) - tg.cancel_scope.cancel() + assert task_status.status == "failed" + assert task_status.status_message == "Something went wrong!" diff --git a/tests/experimental/tasks/server/test_run_task_flow.py b/tests/experimental/tasks/server/test_run_task_flow.py index 7f680beb6..027382e69 100644 --- a/tests/experimental/tasks/server/test_run_task_flow.py +++ b/tests/experimental/tasks/server/test_run_task_flow.py @@ -1,5 +1,4 @@ -""" -Tests for the simplified task API: enable_tasks() + run_task() +"""Tests for the simplified task API: enable_tasks() + run_task() This tests the recommended user flow: 1. server.experimental.enable_tasks() - one-line setup @@ -9,162 +8,102 @@ These are integration tests that verify the complete flow works end-to-end. """ -from typing import Any from unittest.mock import Mock import anyio import pytest from anyio import Event -from mcp.client.session import ClientSession -from mcp.server import Server +from mcp import Client +from mcp.server import Server, ServerRequestContext from mcp.server.experimental.request_context import Experimental from mcp.server.experimental.task_context import ServerTaskContext from mcp.server.experimental.task_support import TaskSupport from mcp.server.lowlevel import NotificationOptions from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore from mcp.shared.experimental.tasks.message_queue import InMemoryTaskMessageQueue -from mcp.shared.message import SessionMessage from mcp.types import ( TASK_REQUIRED, + CallToolRequestParams, CallToolResult, - CancelTaskRequest, - CancelTaskResult, CreateTaskResult, - GetTaskPayloadRequest, - GetTaskPayloadResult, - GetTaskRequest, + GetTaskRequestParams, GetTaskResult, - ListTasksRequest, - ListTasksResult, + ListToolsResult, + PaginatedRequestParams, TextContent, - Tool, - ToolExecution, ) +pytestmark = pytest.mark.anyio -@pytest.mark.anyio -async def test_run_task_basic_flow() -> None: - """ - Test the basic run_task flow without elicitation. - - 1. enable_tasks() sets up handlers - 2. Client calls tool with task field - 3. run_task() spawns work, returns CreateTaskResult - 4. Work completes in background - 5. Client polls and sees completed status - """ - server = Server("test-run-task") - - # One-line setup - server.experimental.enable_tasks() - # Track when work completes and capture received meta +async def _handle_list_tools_simple_task( + ctx: ServerRequestContext, params: PaginatedRequestParams | None +) -> ListToolsResult: + raise NotImplementedError + + +async def test_run_task_basic_flow() -> None: + """Test the basic run_task flow without elicitation.""" work_completed = Event() received_meta: list[str | None] = [None] - @server.list_tools() - async def list_tools() -> list[Tool]: - return [ - Tool( - name="simple_task", - description="A simple task", - inputSchema={"type": "object", "properties": {"input": {"type": "string"}}}, - execution=ToolExecution(taskSupport=TASK_REQUIRED), - ) - ] - - @server.call_tool() - async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult | CreateTaskResult: - ctx = server.request_context + async def handle_call_tool( + ctx: ServerRequestContext, params: CallToolRequestParams + ) -> CallToolResult | CreateTaskResult: ctx.experimental.validate_task_mode(TASK_REQUIRED) - # Capture the meta from the request (if present) - if ctx.meta is not None and ctx.meta.model_extra: # pragma: no branch - received_meta[0] = ctx.meta.model_extra.get("custom_field") + if ctx.meta is not None: # pragma: no branch + received_meta[0] = ctx.meta.get("custom_field") async def work(task: ServerTaskContext) -> CallToolResult: await task.update_status("Working...") - input_val = arguments.get("input", "default") + input_val = (params.arguments or {}).get("input", "default") result = CallToolResult(content=[TextContent(type="text", text=f"Processed: {input_val}")]) work_completed.set() return result return await ctx.experimental.run_task(work) - # Set up streams - server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) - client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) - - async def run_server() -> None: - await server.run( - client_to_server_receive, - server_to_client_send, - server.create_initialization_options( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), + server = Server( + "test-run-task", + on_list_tools=_handle_list_tools_simple_task, + on_call_tool=handle_call_tool, + ) + server.experimental.enable_tasks() + + async with Client(server) as client: + result = await client.session.experimental.call_tool_as_task( + "simple_task", + {"input": "hello"}, + meta={"custom_field": "test_value"}, ) - async def run_client() -> None: - async with ClientSession(server_to_client_receive, client_to_server_send) as client_session: - # Initialize - await client_session.initialize() - - # Call tool as task (with meta to test that code path) - result = await client_session.experimental.call_tool_as_task( - "simple_task", - {"input": "hello"}, - meta={"custom_field": "test_value"}, - ) - - # Should get CreateTaskResult - task_id = result.task.taskId - assert result.task.status == "working" - - # Wait for work to complete - with anyio.fail_after(5): - await work_completed.wait() - - # Poll until task status is completed - with anyio.fail_after(5): - while True: - task_status = await client_session.experimental.get_task(task_id) - if task_status.status == "completed": # pragma: no branch - break - - async with anyio.create_task_group() as tg: - tg.start_soon(run_server) - tg.start_soon(run_client) - - # Verify the meta was passed through correctly + task_id = result.task.task_id + assert result.task.status == "working" + + with anyio.fail_after(5): + await work_completed.wait() + + with anyio.fail_after(5): + while True: + task_status = await client.session.experimental.get_task(task_id) + if task_status.status == "completed": # pragma: no branch + break + assert received_meta[0] == "test_value" -@pytest.mark.anyio async def test_run_task_auto_fails_on_exception() -> None: - """ - Test that run_task automatically fails the task when work raises. - """ - server = Server("test-run-task-fail") - server.experimental.enable_tasks() - + """Test that run_task automatically fails the task when work raises.""" work_failed = Event() - @server.list_tools() - async def list_tools() -> list[Tool]: - return [ - Tool( - name="failing_task", - description="A task that fails", - inputSchema={"type": "object"}, - execution=ToolExecution(taskSupport=TASK_REQUIRED), - ) - ] - - @server.call_tool() - async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult | CreateTaskResult: - ctx = server.request_context + async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + raise NotImplementedError + + async def handle_call_tool( + ctx: ServerRequestContext, params: CallToolRequestParams + ) -> CallToolResult | CreateTaskResult: ctx.experimental.validate_task_mode(TASK_REQUIRED) async def work(task: ServerTaskContext) -> CallToolResult: @@ -173,46 +112,31 @@ async def work(task: ServerTaskContext) -> CallToolResult: return await ctx.experimental.run_task(work) - server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) - client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) - - async def run_server() -> None: - await server.run( - client_to_server_receive, - server_to_client_send, - server.create_initialization_options(), - ) - - async def run_client() -> None: - async with ClientSession(server_to_client_receive, client_to_server_send) as client_session: - await client_session.initialize() - - result = await client_session.experimental.call_tool_as_task("failing_task", {}) - task_id = result.task.taskId + server = Server( + "test-run-task-fail", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, + ) + server.experimental.enable_tasks() - # Wait for work to fail - with anyio.fail_after(5): - await work_failed.wait() + async with Client(server) as client: + result = await client.session.experimental.call_tool_as_task("failing_task", {}) + task_id = result.task.task_id - # Poll until task status is failed - with anyio.fail_after(5): - while True: - task_status = await client_session.experimental.get_task(task_id) - if task_status.status == "failed": # pragma: no branch - break + with anyio.fail_after(5): + await work_failed.wait() - assert "Something went wrong" in (task_status.statusMessage or "") + with anyio.fail_after(5): + while True: + task_status = await client.session.experimental.get_task(task_id) + if task_status.status == "failed": # pragma: no branch + break - async with anyio.create_task_group() as tg: - tg.start_soon(run_server) - tg.start_soon(run_client) + assert "Something went wrong" in (task_status.status_message or "") -@pytest.mark.anyio async def test_enable_tasks_auto_registers_handlers() -> None: - """ - Test that enable_tasks() auto-registers get_task, list_tasks, cancel_task handlers. - """ + """Test that enable_tasks() auto-registers get_task, list_tasks, cancel_task handlers.""" server = Server("test-enable-tasks") # Before enable_tasks, no task capabilities @@ -227,59 +151,41 @@ async def test_enable_tasks_auto_registers_handlers() -> None: assert caps_after.tasks is not None assert caps_after.tasks.list is not None assert caps_after.tasks.cancel is not None + assert caps_after.tasks.requests is not None + assert caps_after.tasks.requests.tools is not None + assert caps_after.tasks.requests.tools.call is not None -@pytest.mark.anyio async def test_enable_tasks_with_custom_store_and_queue() -> None: """Test that enable_tasks() uses provided store and queue instead of defaults.""" server = Server("test-custom-store-queue") - # Create custom store and queue custom_store = InMemoryTaskStore() custom_queue = InMemoryTaskMessageQueue() - # Enable tasks with custom implementations task_support = server.experimental.enable_tasks(store=custom_store, queue=custom_queue) - # Verify our custom implementations are used assert task_support.store is custom_store assert task_support.queue is custom_queue -@pytest.mark.anyio async def test_enable_tasks_skips_default_handlers_when_custom_registered() -> None: """Test that enable_tasks() doesn't override already-registered handlers.""" server = Server("test-custom-handlers") - # Register custom handlers BEFORE enable_tasks (never called, just for registration) - @server.experimental.get_task() - async def custom_get_task(req: GetTaskRequest) -> GetTaskResult: + # Register custom handlers via enable_tasks kwargs + async def custom_get_task(ctx: ServerRequestContext, params: GetTaskRequestParams) -> GetTaskResult: raise NotImplementedError - @server.experimental.get_task_result() - async def custom_get_task_result(req: GetTaskPayloadRequest) -> GetTaskPayloadResult: - raise NotImplementedError + server.experimental.enable_tasks(on_get_task=custom_get_task) - @server.experimental.list_tasks() - async def custom_list_tasks(req: ListTasksRequest) -> ListTasksResult: - raise NotImplementedError - - @server.experimental.cancel_task() - async def custom_cancel_task(req: CancelTaskRequest) -> CancelTaskResult: - raise NotImplementedError + # Verify handler is registered + assert server._has_handler("tasks/get") + assert server._has_handler("tasks/list") + assert server._has_handler("tasks/cancel") + assert server._has_handler("tasks/result") - # Now enable tasks - should NOT override our custom handlers - server.experimental.enable_tasks() - # Verify our custom handlers are still registered (not replaced by defaults) - # The handlers dict should contain our custom handlers - assert GetTaskRequest in server.request_handlers - assert GetTaskPayloadRequest in server.request_handlers - assert ListTasksRequest in server.request_handlers - assert CancelTaskRequest in server.request_handlers - - -@pytest.mark.anyio async def test_run_task_without_enable_tasks_raises() -> None: """Test that run_task raises when enable_tasks() wasn't called.""" experimental = Experimental( @@ -296,7 +202,6 @@ async def work(task: ServerTaskContext) -> CallToolResult: await experimental.run_task(work) -@pytest.mark.anyio async def test_task_support_task_group_before_run_raises() -> None: """Test that accessing task_group before run() raises RuntimeError.""" task_support = TaskSupport.in_memory() @@ -305,7 +210,6 @@ async def test_task_support_task_group_before_run_raises() -> None: _ = task_support.task_group -@pytest.mark.anyio async def test_run_task_without_session_raises() -> None: """Test that run_task raises when session is not available.""" task_support = TaskSupport.in_memory() @@ -324,7 +228,6 @@ async def work(task: ServerTaskContext) -> CallToolResult: await experimental.run_task(work) -@pytest.mark.anyio async def test_run_task_without_task_metadata_raises() -> None: """Test that run_task raises when request is not task-augmented.""" task_support = TaskSupport.in_memory() @@ -344,29 +247,17 @@ async def work(task: ServerTaskContext) -> CallToolResult: await experimental.run_task(work) -@pytest.mark.anyio async def test_run_task_with_model_immediate_response() -> None: """Test that run_task includes model_immediate_response in CreateTaskResult._meta.""" - server = Server("test-run-task-immediate") - server.experimental.enable_tasks() - work_completed = Event() immediate_response_text = "Processing your request..." - @server.list_tools() - async def list_tools() -> list[Tool]: - return [ - Tool( - name="task_with_immediate", - description="A task with immediate response", - inputSchema={"type": "object"}, - execution=ToolExecution(taskSupport=TASK_REQUIRED), - ) - ] - - @server.call_tool() - async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult | CreateTaskResult: - ctx = server.request_context + async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + raise NotImplementedError + + async def handle_call_tool( + ctx: ServerRequestContext, params: CallToolRequestParams + ) -> CallToolResult | CreateTaskResult: ctx.experimental.validate_task_mode(TASK_REQUIRED) async def work(task: ServerTaskContext) -> CallToolResult: @@ -375,164 +266,102 @@ async def work(task: ServerTaskContext) -> CallToolResult: return await ctx.experimental.run_task(work, model_immediate_response=immediate_response_text) - server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) - client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) - - async def run_server() -> None: - await server.run( - client_to_server_receive, - server_to_client_send, - server.create_initialization_options(), - ) - - async def run_client() -> None: - async with ClientSession(server_to_client_receive, client_to_server_send) as client_session: - await client_session.initialize() - - result = await client_session.experimental.call_tool_as_task("task_with_immediate", {}) + server = Server( + "test-run-task-immediate", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, + ) + server.experimental.enable_tasks() - # Verify the immediate response is in _meta - assert result.meta is not None - assert "io.modelcontextprotocol/model-immediate-response" in result.meta - assert result.meta["io.modelcontextprotocol/model-immediate-response"] == immediate_response_text + async with Client(server) as client: + result = await client.session.experimental.call_tool_as_task("task_with_immediate", {}) - with anyio.fail_after(5): - await work_completed.wait() + assert result.meta is not None + assert "io.modelcontextprotocol/model-immediate-response" in result.meta + assert result.meta["io.modelcontextprotocol/model-immediate-response"] == immediate_response_text - async with anyio.create_task_group() as tg: - tg.start_soon(run_server) - tg.start_soon(run_client) + with anyio.fail_after(5): + await work_completed.wait() -@pytest.mark.anyio async def test_run_task_doesnt_complete_if_already_terminal() -> None: """Test that run_task doesn't auto-complete if work manually completed the task.""" - server = Server("test-already-complete") - server.experimental.enable_tasks() - work_completed = Event() - @server.list_tools() - async def list_tools() -> list[Tool]: - return [ - Tool( - name="manual_complete_task", - description="A task that manually completes", - inputSchema={"type": "object"}, - execution=ToolExecution(taskSupport=TASK_REQUIRED), - ) - ] - - @server.call_tool() - async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult | CreateTaskResult: - ctx = server.request_context + async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + raise NotImplementedError + + async def handle_call_tool( + ctx: ServerRequestContext, params: CallToolRequestParams + ) -> CallToolResult | CreateTaskResult: ctx.experimental.validate_task_mode(TASK_REQUIRED) async def work(task: ServerTaskContext) -> CallToolResult: - # Manually complete the task before returning manual_result = CallToolResult(content=[TextContent(type="text", text="Manually completed")]) await task.complete(manual_result, notify=False) work_completed.set() - # Return a different result - but it should be ignored since task is already terminal return CallToolResult(content=[TextContent(type="text", text="This should be ignored")]) return await ctx.experimental.run_task(work) - server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) - client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) - - async def run_server() -> None: - await server.run( - client_to_server_receive, - server_to_client_send, - server.create_initialization_options(), - ) - - async def run_client() -> None: - async with ClientSession(server_to_client_receive, client_to_server_send) as client_session: - await client_session.initialize() - - result = await client_session.experimental.call_tool_as_task("manual_complete_task", {}) - task_id = result.task.taskId + server = Server( + "test-already-complete", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, + ) + server.experimental.enable_tasks() - with anyio.fail_after(5): - await work_completed.wait() + async with Client(server) as client: + result = await client.session.experimental.call_tool_as_task("manual_complete_task", {}) + task_id = result.task.task_id - # Poll until task status is completed - with anyio.fail_after(5): - while True: - status = await client_session.experimental.get_task(task_id) - if status.status == "completed": # pragma: no branch - break + with anyio.fail_after(5): + await work_completed.wait() - async with anyio.create_task_group() as tg: - tg.start_soon(run_server) - tg.start_soon(run_client) + with anyio.fail_after(5): + while True: + status = await client.session.experimental.get_task(task_id) + if status.status == "completed": # pragma: no branch + break -@pytest.mark.anyio async def test_run_task_doesnt_fail_if_already_terminal() -> None: """Test that run_task doesn't auto-fail if work manually failed/cancelled the task.""" - server = Server("test-already-failed") - server.experimental.enable_tasks() - work_completed = Event() - @server.list_tools() - async def list_tools() -> list[Tool]: - return [ - Tool( - name="manual_cancel_task", - description="A task that manually cancels then raises", - inputSchema={"type": "object"}, - execution=ToolExecution(taskSupport=TASK_REQUIRED), - ) - ] - - @server.call_tool() - async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult | CreateTaskResult: - ctx = server.request_context + async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + raise NotImplementedError + + async def handle_call_tool( + ctx: ServerRequestContext, params: CallToolRequestParams + ) -> CallToolResult | CreateTaskResult: ctx.experimental.validate_task_mode(TASK_REQUIRED) async def work(task: ServerTaskContext) -> CallToolResult: - # Manually fail the task first await task.fail("Manually failed", notify=False) work_completed.set() - # Then raise - but the auto-fail should be skipped since task is already terminal raise RuntimeError("This error should not change status") return await ctx.experimental.run_task(work) - server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) - client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) - - async def run_server() -> None: - await server.run( - client_to_server_receive, - server_to_client_send, - server.create_initialization_options(), - ) - - async def run_client() -> None: - async with ClientSession(server_to_client_receive, client_to_server_send) as client_session: - await client_session.initialize() - - result = await client_session.experimental.call_tool_as_task("manual_cancel_task", {}) - task_id = result.task.taskId + server = Server( + "test-already-failed", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, + ) + server.experimental.enable_tasks() - with anyio.fail_after(5): - await work_completed.wait() + async with Client(server) as client: + result = await client.session.experimental.call_tool_as_task("manual_cancel_task", {}) + task_id = result.task.task_id - # Poll until task status is failed - with anyio.fail_after(5): - while True: - status = await client_session.experimental.get_task(task_id) - if status.status == "failed": # pragma: no branch - break + with anyio.fail_after(5): + await work_completed.wait() - # Task should still be failed (from manual fail, not auto-fail from exception) - assert status.statusMessage == "Manually failed" # Not "This error should not change status" + with anyio.fail_after(5): + while True: + status = await client.session.experimental.get_task(task_id) + if status.status == "failed": # pragma: no branch + break - async with anyio.create_task_group() as tg: - tg.start_soon(run_server) - tg.start_soon(run_client) + assert status.status_message == "Manually failed" diff --git a/tests/experimental/tasks/server/test_server.py b/tests/experimental/tasks/server/test_server.py index 7209ed412..6a28b274e 100644 --- a/tests/experimental/tasks/server/test_server.py +++ b/tests/experimental/tasks/server/test_server.py @@ -6,12 +6,13 @@ import anyio import pytest +from mcp import Client from mcp.client.session import ClientSession -from mcp.server import Server +from mcp.server import Server, ServerRequestContext from mcp.server.lowlevel import NotificationOptions from mcp.server.models import InitializationOptions from mcp.server.session import ServerSession -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.shared.message import ServerMessageMetadata, SessionMessage from mcp.shared.response_router import ResponseRouter from mcp.shared.session import RequestResponder @@ -23,31 +24,25 @@ CallToolRequest, CallToolRequestParams, CallToolResult, - CancelTaskRequest, CancelTaskRequestParams, CancelTaskResult, - ClientRequest, ClientResult, ErrorData, GetTaskPayloadRequest, GetTaskPayloadRequestParams, GetTaskPayloadResult, - GetTaskRequest, GetTaskRequestParams, GetTaskResult, JSONRPCError, - JSONRPCMessage, JSONRPCNotification, JSONRPCResponse, - ListTasksRequest, ListTasksResult, - ListToolsRequest, ListToolsResult, + PaginatedRequestParams, SamplingMessage, ServerCapabilities, ServerNotification, ServerRequest, - ServerResult, Task, TaskMetadata, TextContent, @@ -55,143 +50,107 @@ ToolExecution, ) +pytestmark = pytest.mark.anyio -@pytest.mark.anyio -async def test_list_tasks_handler() -> None: - """Test that experimental list_tasks handler works.""" - server = Server("test") +async def test_list_tasks_handler() -> None: + """Test that experimental list_tasks handler works via Client.""" now = datetime.now(timezone.utc) test_tasks = [ - Task( - taskId="task-1", - status="working", - createdAt=now, - lastUpdatedAt=now, - ttl=60000, - pollInterval=1000, - ), - Task( - taskId="task-2", - status="completed", - createdAt=now, - lastUpdatedAt=now, - ttl=60000, - pollInterval=1000, - ), + Task(task_id="task-1", status="working", created_at=now, last_updated_at=now, ttl=60000, poll_interval=1000), + Task(task_id="task-2", status="completed", created_at=now, last_updated_at=now, ttl=60000, poll_interval=1000), ] - @server.experimental.list_tasks() - async def handle_list_tasks(request: ListTasksRequest) -> ListTasksResult: + async def handle_list_tasks(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListTasksResult: return ListTasksResult(tasks=test_tasks) - handler = server.request_handlers[ListTasksRequest] - request = ListTasksRequest(method="tasks/list") - result = await handler(request) + server = Server("test") + server.experimental.enable_tasks(on_list_tasks=handle_list_tasks) - assert isinstance(result, ServerResult) - assert isinstance(result.root, ListTasksResult) - assert len(result.root.tasks) == 2 - assert result.root.tasks[0].taskId == "task-1" - assert result.root.tasks[1].taskId == "task-2" + async with Client(server) as client: + result = await client.session.experimental.list_tasks() + assert len(result.tasks) == 2 + assert result.tasks[0].task_id == "task-1" + assert result.tasks[1].task_id == "task-2" -@pytest.mark.anyio async def test_get_task_handler() -> None: - """Test that experimental get_task handler works.""" - server = Server("test") + """Test that experimental get_task handler works via Client.""" - @server.experimental.get_task() - async def handle_get_task(request: GetTaskRequest) -> GetTaskResult: + async def handle_get_task(ctx: ServerRequestContext, params: GetTaskRequestParams) -> GetTaskResult: now = datetime.now(timezone.utc) return GetTaskResult( - taskId=request.params.taskId, + task_id=params.task_id, status="working", - createdAt=now, - lastUpdatedAt=now, + created_at=now, + last_updated_at=now, ttl=60000, - pollInterval=1000, + poll_interval=1000, ) - handler = server.request_handlers[GetTaskRequest] - request = GetTaskRequest( - method="tasks/get", - params=GetTaskRequestParams(taskId="test-task-123"), - ) - result = await handler(request) + server = Server("test") + server.experimental.enable_tasks(on_get_task=handle_get_task) - assert isinstance(result, ServerResult) - assert isinstance(result.root, GetTaskResult) - assert result.root.taskId == "test-task-123" - assert result.root.status == "working" + async with Client(server) as client: + result = await client.session.experimental.get_task("test-task-123") + assert result.task_id == "test-task-123" + assert result.status == "working" -@pytest.mark.anyio async def test_get_task_result_handler() -> None: - """Test that experimental get_task_result handler works.""" - server = Server("test") + """Test that experimental get_task_result handler works via Client.""" - @server.experimental.get_task_result() - async def handle_get_task_result(request: GetTaskPayloadRequest) -> GetTaskPayloadResult: + async def handle_get_task_result( + ctx: ServerRequestContext, params: GetTaskPayloadRequestParams + ) -> GetTaskPayloadResult: return GetTaskPayloadResult() - handler = server.request_handlers[GetTaskPayloadRequest] - request = GetTaskPayloadRequest( - method="tasks/result", - params=GetTaskPayloadRequestParams(taskId="test-task-123"), - ) - result = await handler(request) + server = Server("test") + server.experimental.enable_tasks(on_task_result=handle_get_task_result) - assert isinstance(result, ServerResult) - assert isinstance(result.root, GetTaskPayloadResult) + async with Client(server) as client: + result = await client.session.send_request( + GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(task_id="test-task-123")), + GetTaskPayloadResult, + ) + assert isinstance(result, GetTaskPayloadResult) -@pytest.mark.anyio async def test_cancel_task_handler() -> None: - """Test that experimental cancel_task handler works.""" - server = Server("test") + """Test that experimental cancel_task handler works via Client.""" - @server.experimental.cancel_task() - async def handle_cancel_task(request: CancelTaskRequest) -> CancelTaskResult: + async def handle_cancel_task(ctx: ServerRequestContext, params: CancelTaskRequestParams) -> CancelTaskResult: now = datetime.now(timezone.utc) return CancelTaskResult( - taskId=request.params.taskId, + task_id=params.task_id, status="cancelled", - createdAt=now, - lastUpdatedAt=now, + created_at=now, + last_updated_at=now, ttl=60000, ) - handler = server.request_handlers[CancelTaskRequest] - request = CancelTaskRequest( - method="tasks/cancel", - params=CancelTaskRequestParams(taskId="test-task-123"), - ) - result = await handler(request) + server = Server("test") + server.experimental.enable_tasks(on_cancel_task=handle_cancel_task) - assert isinstance(result, ServerResult) - assert isinstance(result.root, CancelTaskResult) - assert result.root.taskId == "test-task-123" - assert result.root.status == "cancelled" + async with Client(server) as client: + result = await client.session.experimental.cancel_task("test-task-123") + assert result.task_id == "test-task-123" + assert result.status == "cancelled" -@pytest.mark.anyio async def test_server_capabilities_include_tasks() -> None: """Test that server capabilities include tasks when handlers are registered.""" server = Server("test") - @server.experimental.list_tasks() - async def handle_list_tasks(request: ListTasksRequest) -> ListTasksResult: + async def noop_list_tasks(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListTasksResult: raise NotImplementedError - @server.experimental.cancel_task() - async def handle_cancel_task(request: CancelTaskRequest) -> CancelTaskResult: + async def noop_cancel_task(ctx: ServerRequestContext, params: CancelTaskRequestParams) -> CancelTaskResult: raise NotImplementedError - capabilities = server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ) + server.experimental.enable_tasks(on_list_tasks=noop_list_tasks, on_cancel_task=noop_cancel_task) + + capabilities = server.get_capabilities(notification_options=NotificationOptions(), experimental_capabilities={}) assert capabilities.tasks is not None assert capabilities.tasks.list is not None @@ -200,271 +159,164 @@ async def handle_cancel_task(request: CancelTaskRequest) -> CancelTaskResult: assert capabilities.tasks.requests.tools is not None -@pytest.mark.anyio -async def test_server_capabilities_partial_tasks() -> None: +@pytest.mark.skip( + reason="TODO(maxisbey): enable_tasks registers default handlers for all task methods, " + "so partial capabilities aren't possible yet. Low-level API should support " + "selectively enabling/disabling task capabilities." +) +async def test_server_capabilities_partial_tasks() -> None: # pragma: no cover """Test capabilities with only some task handlers registered.""" server = Server("test") - @server.experimental.list_tasks() - async def handle_list_tasks(request: ListTasksRequest) -> ListTasksResult: + async def noop_list_tasks(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListTasksResult: raise NotImplementedError # Only list_tasks registered, not cancel_task + server.experimental.enable_tasks(on_list_tasks=noop_list_tasks) - capabilities = server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ) + capabilities = server.get_capabilities(notification_options=NotificationOptions(), experimental_capabilities={}) assert capabilities.tasks is not None assert capabilities.tasks.list is not None assert capabilities.tasks.cancel is None # Not registered -@pytest.mark.anyio async def test_tool_with_task_execution_metadata() -> None: """Test that tools can declare task execution mode.""" - server = Server("test") - @server.list_tools() - async def list_tools(): - return [ - Tool( - name="quick_tool", - description="Fast tool", - inputSchema={"type": "object", "properties": {}}, - execution=ToolExecution(taskSupport=TASK_FORBIDDEN), - ), - Tool( - name="long_tool", - description="Long running tool", - inputSchema={"type": "object", "properties": {}}, - execution=ToolExecution(taskSupport=TASK_REQUIRED), - ), - Tool( - name="flexible_tool", - description="Can be either", - inputSchema={"type": "object", "properties": {}}, - execution=ToolExecution(taskSupport=TASK_OPTIONAL), - ), - ] + async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult( + tools=[ + Tool( + name="quick_tool", + description="Fast tool", + input_schema={"type": "object", "properties": {}}, + execution=ToolExecution(task_support=TASK_FORBIDDEN), + ), + Tool( + name="long_tool", + description="Long running tool", + input_schema={"type": "object", "properties": {}}, + execution=ToolExecution(task_support=TASK_REQUIRED), + ), + Tool( + name="flexible_tool", + description="Can be either", + input_schema={"type": "object", "properties": {}}, + execution=ToolExecution(task_support=TASK_OPTIONAL), + ), + ] + ) - tools_handler = server.request_handlers[ListToolsRequest] - request = ListToolsRequest(method="tools/list") - result = await tools_handler(request) + server = Server("test", on_list_tools=handle_list_tools) - assert isinstance(result, ServerResult) - assert isinstance(result.root, ListToolsResult) - tools = result.root.tools + async with Client(server) as client: + result = await client.list_tools() + tools = result.tools - assert tools[0].execution is not None - assert tools[0].execution.taskSupport == TASK_FORBIDDEN - assert tools[1].execution is not None - assert tools[1].execution.taskSupport == TASK_REQUIRED - assert tools[2].execution is not None - assert tools[2].execution.taskSupport == TASK_OPTIONAL + assert tools[0].execution is not None + assert tools[0].execution.task_support == TASK_FORBIDDEN + assert tools[1].execution is not None + assert tools[1].execution.task_support == TASK_REQUIRED + assert tools[2].execution is not None + assert tools[2].execution.task_support == TASK_OPTIONAL -@pytest.mark.anyio async def test_task_metadata_in_call_tool_request() -> None: - """Test that task metadata is accessible via RequestContext when calling a tool.""" - server = Server("test") + """Test that task metadata is accessible via ctx when calling a tool.""" captured_task_metadata: TaskMetadata | None = None - @server.list_tools() - async def list_tools(): - return [ - Tool( - name="long_task", - description="A long running task", - inputSchema={"type": "object", "properties": {}}, - execution=ToolExecution(taskSupport="optional"), - ) - ] + async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + raise NotImplementedError - @server.call_tool() - async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: + async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: nonlocal captured_task_metadata - ctx = server.request_context captured_task_metadata = ctx.experimental.task_metadata - return [TextContent(type="text", text="done")] - - server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) - client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) - - async def message_handler( - message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, - ) -> None: ... # pragma: no branch - - async def run_server(): - async with ServerSession( - client_to_server_receive, - server_to_client_send, - InitializationOptions( - server_name="test-server", - server_version="1.0.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, + return CallToolResult(content=[TextContent(type="text", text="done")]) + + server = Server("test", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool) + + async with Client(server) as client: + # Call tool with task metadata + await client.session.send_request( + CallToolRequest( + params=CallToolRequestParams( + name="long_task", + arguments={}, + task=TaskMetadata(ttl=60000), ), ), - ) as server_session: - async with anyio.create_task_group() as tg: - - async def handle_messages(): - async for message in server_session.incoming_messages: - await server._handle_message(message, server_session, {}, False) - - tg.start_soon(handle_messages) - await anyio.sleep_forever() - - async with anyio.create_task_group() as tg: - tg.start_soon(run_server) - - async with ClientSession( - server_to_client_receive, - client_to_server_send, - message_handler=message_handler, - ) as client_session: - await client_session.initialize() - - # Call tool with task metadata - await client_session.send_request( - ClientRequest( - CallToolRequest( - params=CallToolRequestParams( - name="long_task", - arguments={}, - task=TaskMetadata(ttl=60000), - ), - ) - ), - CallToolResult, - ) - - tg.cancel_scope.cancel() + CallToolResult, + ) assert captured_task_metadata is not None assert captured_task_metadata.ttl == 60000 -@pytest.mark.anyio async def test_task_metadata_is_task_property() -> None: - """Test that RequestContext.experimental.is_task works correctly.""" - server = Server("test") + """Test that ctx.experimental.is_task works correctly.""" is_task_values: list[bool] = [] - @server.list_tools() - async def list_tools(): - return [ - Tool( - name="test_tool", - description="Test tool", - inputSchema={"type": "object", "properties": {}}, - ) - ] + async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + raise NotImplementedError - @server.call_tool() - async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: - ctx = server.request_context + async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: is_task_values.append(ctx.experimental.is_task) - return [TextContent(type="text", text="done")] + return CallToolResult(content=[TextContent(type="text", text="done")]) - server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) - client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + server = Server("test", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool) - async def message_handler( - message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, - ) -> None: ... # pragma: no branch + async with Client(server) as client: + # Call without task metadata + await client.session.send_request( + CallToolRequest(params=CallToolRequestParams(name="test_tool", arguments={})), + CallToolResult, + ) - async def run_server(): - async with ServerSession( - client_to_server_receive, - server_to_client_send, - InitializationOptions( - server_name="test-server", - server_version="1.0.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), + # Call with task metadata + await client.session.send_request( + CallToolRequest( + params=CallToolRequestParams(name="test_tool", arguments={}, task=TaskMetadata(ttl=60000)), ), - ) as server_session: - async with anyio.create_task_group() as tg: - - async def handle_messages(): - async for message in server_session.incoming_messages: - await server._handle_message(message, server_session, {}, False) - - tg.start_soon(handle_messages) - await anyio.sleep_forever() - - async with anyio.create_task_group() as tg: - tg.start_soon(run_server) - - async with ClientSession( - server_to_client_receive, - client_to_server_send, - message_handler=message_handler, - ) as client_session: - await client_session.initialize() - - # Call without task metadata - await client_session.send_request( - ClientRequest( - CallToolRequest( - params=CallToolRequestParams(name="test_tool", arguments={}), - ) - ), - CallToolResult, - ) - - # Call with task metadata - await client_session.send_request( - ClientRequest( - CallToolRequest( - params=CallToolRequestParams( - name="test_tool", - arguments={}, - task=TaskMetadata(ttl=60000), - ), - ) - ), - CallToolResult, - ) - - tg.cancel_scope.cancel() + CallToolResult, + ) assert len(is_task_values) == 2 assert is_task_values[0] is False # First call without task assert is_task_values[1] is True # Second call with task -@pytest.mark.anyio async def test_update_capabilities_no_handlers() -> None: """Test that update_capabilities returns early when no task handlers are registered.""" server = Server("test-no-handlers") - # Access experimental to initialize it, but don't register any task handlers _ = server.experimental caps = server.get_capabilities(NotificationOptions(), {}) - - # Without any task handlers registered, tasks capability should be None assert caps.tasks is None -@pytest.mark.anyio +async def test_update_capabilities_partial_handlers() -> None: + """Test that update_capabilities skips list/cancel when only tasks/get is registered.""" + server = Server("test-partial") + # Access .experimental to create the ExperimentalHandlers instance + exp = server.experimental + # Second access returns the same cached instance + assert server.experimental is exp + + async def noop_get(ctx: ServerRequestContext, params: GetTaskRequestParams) -> GetTaskResult: + raise NotImplementedError + + server._add_request_handler("tasks/get", noop_get) + + caps = server.get_capabilities(NotificationOptions(), {}) + assert caps.tasks is not None + assert caps.tasks.list is None + assert caps.tasks.cancel is None + + async def test_default_task_handlers_via_enable_tasks() -> None: - """Test that enable_tasks() auto-registers working default handlers. - - This exercises the default handlers in lowlevel/experimental.py: - - _default_get_task (task not found) - - _default_get_task_result - - _default_list_tasks - - _default_cancel_task - """ + """Test that enable_tasks() auto-registers working default handlers.""" server = Server("test-default-handlers") - # Enable tasks with default handlers (no custom handlers registered) task_support = server.experimental.enable_tasks() store = task_support.store @@ -507,38 +359,29 @@ async def run_server() -> None: task = await store.create_task(TaskMetadata(ttl=60000)) # Test list_tasks (default handler) - list_result = await client_session.send_request( - ClientRequest(ListTasksRequest()), - ListTasksResult, - ) + list_result = await client_session.experimental.list_tasks() assert len(list_result.tasks) == 1 - assert list_result.tasks[0].taskId == task.taskId + assert list_result.tasks[0].task_id == task.task_id # Test get_task (default handler - found) - get_result = await client_session.send_request( - ClientRequest(GetTaskRequest(params=GetTaskRequestParams(taskId=task.taskId))), - GetTaskResult, - ) - assert get_result.taskId == task.taskId + get_result = await client_session.experimental.get_task(task.task_id) + assert get_result.task_id == task.task_id assert get_result.status == "working" # Test get_task (default handler - not found path) - with pytest.raises(McpError, match="not found"): - await client_session.send_request( - ClientRequest(GetTaskRequest(params=GetTaskRequestParams(taskId="nonexistent-task"))), - GetTaskResult, - ) + with pytest.raises(MCPError, match="not found"): + await client_session.experimental.get_task("nonexistent-task") # Create a completed task to test get_task_result completed_task = await store.create_task(TaskMetadata(ttl=60000)) await store.store_result( - completed_task.taskId, CallToolResult(content=[TextContent(type="text", text="Test result")]) + completed_task.task_id, CallToolResult(content=[TextContent(type="text", text="Test result")]) ) - await store.update_task(completed_task.taskId, status="completed") + await store.update_task(completed_task.task_id, status="completed") # Test get_task_result (default handler) payload_result = await client_session.send_request( - ClientRequest(GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId=completed_task.taskId))), + GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(task_id=completed_task.task_id)), GetTaskPayloadResult, ) # The result should have the related-task metadata @@ -546,11 +389,8 @@ async def run_server() -> None: assert "io.modelcontextprotocol/related-task" in payload_result.meta # Test cancel_task (default handler) - cancel_result = await client_session.send_request( - ClientRequest(CancelTaskRequest(params=CancelTaskRequestParams(taskId=task.taskId))), - CancelTaskResult, - ) - assert cancel_result.taskId == task.taskId + cancel_result = await client_session.experimental.cancel_task(task.task_id) + assert cancel_result.task_id == task.task_id assert cancel_result.status == "cancelled" tg.cancel_scope.cancel() @@ -566,16 +406,12 @@ async def test_build_elicit_form_request() -> None: async with ServerSession( client_to_server_receive, server_to_client_send, - InitializationOptions( - server_name="test-server", - server_version="1.0.0", - capabilities=ServerCapabilities(), - ), + InitializationOptions(server_name="test-server", server_version="1.0.0", capabilities=ServerCapabilities()), ) as server_session: # Test without task_id request = server_session._build_elicit_form_request( message="Test message", - requestedSchema={"type": "object", "properties": {"answer": {"type": "string"}}}, + requested_schema={"type": "object", "properties": {"answer": {"type": "string"}}}, ) assert request.method == "elicitation/create" assert request.params is not None @@ -584,7 +420,7 @@ async def test_build_elicit_form_request() -> None: # Test with related_task_id (adds related-task metadata) request_with_task = server_session._build_elicit_form_request( message="Task message", - requestedSchema={"type": "object"}, + requested_schema={"type": "object"}, related_task_id="test-task-123", ) assert request_with_task.method == "elicitation/create" @@ -594,7 +430,7 @@ async def test_build_elicit_form_request() -> None: assert ( request_with_task.params["_meta"]["io.modelcontextprotocol/related-task"]["taskId"] == "test-task-123" ) - finally: # pragma: no cover + finally: await server_to_client_send.aclose() await server_to_client_receive.aclose() await client_to_server_send.aclose() @@ -611,11 +447,7 @@ async def test_build_elicit_url_request() -> None: async with ServerSession( client_to_server_receive, server_to_client_send, - InitializationOptions( - server_name="test-server", - server_version="1.0.0", - capabilities=ServerCapabilities(), - ), + InitializationOptions(server_name="test-server", server_version="1.0.0", capabilities=ServerCapabilities()), ) as server_session: # Test without related_task_id request = server_session._build_elicit_url_request( @@ -644,7 +476,7 @@ async def test_build_elicit_url_request() -> None: assert ( request_with_task.params["_meta"]["io.modelcontextprotocol/related-task"]["taskId"] == "test-task-789" ) - finally: # pragma: no cover + finally: await server_to_client_send.aclose() await server_to_client_receive.aclose() await client_to_server_send.aclose() @@ -695,7 +527,7 @@ async def test_build_create_message_request() -> None: request_with_task.params["_meta"]["io.modelcontextprotocol/related-task"]["taskId"] == "sampling-task-456" ) - finally: # pragma: no cover + finally: await server_to_client_send.aclose() await server_to_client_receive.aclose() await client_to_server_send.aclose() @@ -721,7 +553,7 @@ async def test_send_message() -> None: # Create a test message notification = JSONRPCNotification(jsonrpc="2.0", method="test/notification") message = SessionMessage( - message=JSONRPCMessage(notification), + message=notification, metadata=ServerMessageMetadata(related_request_id="test-req-1"), ) @@ -730,9 +562,9 @@ async def test_send_message() -> None: # Verify it was sent to the stream received = await server_to_client_receive.receive() - assert isinstance(received.message.root, JSONRPCNotification) - assert received.message.root.method == "test/notification" - finally: # pragma: no cover + assert isinstance(received.message, JSONRPCNotification) + assert received.message.method == "test/notification" + finally: # pragma: lax no cover await server_to_client_send.aclose() await server_to_client_receive.aclose() await client_to_server_send.aclose() @@ -773,7 +605,7 @@ def route_error(self, request_id: str | int, error: ErrorData) -> bool: # Simulate receiving a response from client response = JSONRPCResponse(jsonrpc="2.0", id="test-req-1", result={"status": "ok"}) - message = SessionMessage(message=JSONRPCMessage(response)) + message = SessionMessage(message=response) # Send from "client" side await client_to_server_send.send(message) @@ -786,7 +618,7 @@ def route_error(self, request_id: str | int, error: ErrorData) -> bool: assert len(routed_responses) == 1 assert routed_responses[0]["id"] == "test-req-1" assert routed_responses[0]["response"]["status"] == "ok" - finally: # pragma: no cover + finally: # pragma: lax no cover await server_to_client_send.aclose() await server_to_client_receive.aclose() await client_to_server_send.aclose() @@ -828,7 +660,7 @@ def route_error(self, request_id: str | int, error: ErrorData) -> bool: # Simulate receiving an error response from client error_data = ErrorData(code=INVALID_REQUEST, message="Test error") error_response = JSONRPCError(jsonrpc="2.0", id="test-req-2", error=error_data) - message = SessionMessage(message=JSONRPCMessage(error_response)) + message = SessionMessage(message=error_response) # Send from "client" side await client_to_server_send.send(message) @@ -841,7 +673,7 @@ def route_error(self, request_id: str | int, error: ErrorData) -> bool: assert len(routed_errors) == 1 assert routed_errors[0]["id"] == "test-req-2" assert routed_errors[0]["error"].message == "Test error" - finally: # pragma: no cover + finally: # pragma: lax no cover await server_to_client_send.aclose() await server_to_client_receive.aclose() await client_to_server_send.aclose() @@ -891,7 +723,7 @@ def route_error(self, request_id: str | int, error: ErrorData) -> bool: # Send a response - should skip first router and be handled by second response = JSONRPCResponse(jsonrpc="2.0", id="test-req-1", result={"status": "ok"}) - message = SessionMessage(message=JSONRPCMessage(response)) + message = SessionMessage(message=response) await client_to_server_send.send(message) with anyio.fail_after(5): @@ -899,7 +731,7 @@ def route_error(self, request_id: str | int, error: ErrorData) -> bool: # Verify both routers were called (first returned False, second returned True) assert router_calls == ["non_matching_response", "matching_response"] - finally: # pragma: no cover + finally: # pragma: lax no cover await server_to_client_send.aclose() await server_to_client_receive.aclose() await client_to_server_send.aclose() @@ -950,7 +782,7 @@ def route_error(self, request_id: str | int, error: ErrorData) -> bool: # Send an error - should skip first router and be handled by second error_data = ErrorData(code=INVALID_REQUEST, message="Test error") error_response = JSONRPCError(jsonrpc="2.0", id="test-req-2", error=error_data) - message = SessionMessage(message=JSONRPCMessage(error_response)) + message = SessionMessage(message=error_response) await client_to_server_send.send(message) with anyio.fail_after(5): @@ -958,7 +790,7 @@ def route_error(self, request_id: str | int, error: ErrorData) -> bool: # Verify both routers were called (first returned False, second returned True) assert router_calls == ["non_matching_error", "matching_error"] - finally: # pragma: no cover + finally: # pragma: lax no cover await server_to_client_send.aclose() await server_to_client_receive.aclose() await client_to_server_send.aclose() diff --git a/tests/experimental/tasks/server/test_server_task_context.py b/tests/experimental/tasks/server/test_server_task_context.py index 3d6b16f48..e23299698 100644 --- a/tests/experimental/tasks/server/test_server_task_context.py +++ b/tests/experimental/tasks/server/test_server_task_context.py @@ -8,7 +8,7 @@ from mcp.server.experimental.task_context import ServerTaskContext from mcp.server.experimental.task_result_handler import TaskResultHandler -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore from mcp.shared.experimental.tasks.message_queue import InMemoryTaskMessageQueue from mcp.types import ( @@ -45,7 +45,7 @@ async def test_server_task_context_properties() -> None: ) assert ctx.task_id == "test-123" - assert ctx.task.taskId == "test-123" + assert ctx.task.task_id == "test-123" assert ctx.is_cancelled is False store.cleanup() @@ -164,7 +164,7 @@ async def test_server_task_context_fail_with_notify() -> None: @pytest.mark.anyio async def test_elicit_raises_when_client_lacks_capability() -> None: - """Test that elicit() raises McpError when client doesn't support elicitation.""" + """Test that elicit() raises MCPError when client doesn't support elicitation.""" store = InMemoryTaskStore() mock_session = Mock() mock_session.check_client_capability = Mock(return_value=False) @@ -180,8 +180,8 @@ async def test_elicit_raises_when_client_lacks_capability() -> None: handler=handler, ) - with pytest.raises(McpError) as exc_info: - await ctx.elicit(message="Test?", requestedSchema={"type": "object"}) + with pytest.raises(MCPError) as exc_info: + await ctx.elicit(message="Test?", requested_schema={"type": "object"}) assert "elicitation capability" in exc_info.value.error.message mock_session.check_client_capability.assert_called_once() @@ -190,7 +190,7 @@ async def test_elicit_raises_when_client_lacks_capability() -> None: @pytest.mark.anyio async def test_create_message_raises_when_client_lacks_capability() -> None: - """Test that create_message() raises McpError when client doesn't support sampling.""" + """Test that create_message() raises MCPError when client doesn't support sampling.""" store = InMemoryTaskStore() mock_session = Mock() mock_session.check_client_capability = Mock(return_value=False) @@ -206,7 +206,7 @@ async def test_create_message_raises_when_client_lacks_capability() -> None: handler=handler, ) - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: await ctx.create_message(messages=[], max_tokens=100) assert "sampling capability" in exc_info.value.error.message @@ -232,7 +232,7 @@ async def test_elicit_raises_without_handler() -> None: ) with pytest.raises(RuntimeError, match="handler is required"): - await ctx.elicit(message="Test?", requestedSchema={"type": "object"}) + await ctx.elicit(message="Test?", requested_schema={"type": "object"}) store.cleanup() @@ -320,22 +320,22 @@ async def run_elicit() -> None: nonlocal elicit_result elicit_result = await ctx.elicit( message="Test?", - requestedSchema={"type": "object"}, + requested_schema={"type": "object"}, ) async with anyio.create_task_group() as tg: tg.start_soon(run_elicit) # Wait for request to be queued - await queue.wait_for_message(task.taskId) + await queue.wait_for_message(task.task_id) # Verify task is in input_required status - updated_task = await store.get_task(task.taskId) + updated_task = await store.get_task(task.task_id) assert updated_task is not None assert updated_task.status == "input_required" # Dequeue and simulate response - msg = await queue.dequeue(task.taskId) + msg = await queue.dequeue(task.task_id) assert msg is not None assert msg.resolver is not None @@ -348,7 +348,7 @@ async def run_elicit() -> None: assert elicit_result.content == {"name": "Alice"} # Verify task is back to working - final_task = await store.get_task(task.taskId) + final_task = await store.get_task(task.task_id) assert final_task is not None assert final_task.status == "working" @@ -396,15 +396,15 @@ async def run_elicit_url() -> None: tg.start_soon(run_elicit_url) # Wait for request to be queued - await queue.wait_for_message(task.taskId) + await queue.wait_for_message(task.task_id) # Verify task is in input_required status - updated_task = await store.get_task(task.taskId) + updated_task = await store.get_task(task.task_id) assert updated_task is not None assert updated_task.status == "input_required" # Dequeue and simulate response - msg = await queue.dequeue(task.taskId) + msg = await queue.dequeue(task.task_id) assert msg is not None assert msg.resolver is not None @@ -416,7 +416,7 @@ async def run_elicit_url() -> None: assert elicit_result.action == "accept" # Verify task is back to working - final_task = await store.get_task(task.taskId) + final_task = await store.get_task(task.task_id) assert final_task is not None assert final_task.status == "working" @@ -463,15 +463,15 @@ async def run_sampling() -> None: tg.start_soon(run_sampling) # Wait for request to be queued - await queue.wait_for_message(task.taskId) + await queue.wait_for_message(task.task_id) # Verify task is in input_required status - updated_task = await store.get_task(task.taskId) + updated_task = await store.get_task(task.task_id) assert updated_task is not None assert updated_task.status == "input_required" # Dequeue and simulate response - msg = await queue.dequeue(task.taskId) + msg = await queue.dequeue(task.task_id) assert msg is not None assert msg.resolver is not None @@ -491,7 +491,7 @@ async def run_sampling() -> None: assert sampling_result.model == "test-model" # Verify task is back to working - final_task = await store.get_task(task.taskId) + final_task = await store.get_task(task.task_id) assert final_task is not None assert final_task.status == "working" @@ -534,7 +534,7 @@ async def do_elicit() -> None: try: await ctx.elicit( message="Test?", - requestedSchema={"type": "object"}, + requested_schema={"type": "object"}, ) except anyio.get_cancelled_exc_class(): cancelled_error_raised = True @@ -543,15 +543,15 @@ async def do_elicit() -> None: tg.start_soon(do_elicit) # Wait for request to be queued - await queue.wait_for_message(task.taskId) + await queue.wait_for_message(task.task_id) # Verify task is in input_required status - updated_task = await store.get_task(task.taskId) + updated_task = await store.get_task(task.task_id) assert updated_task is not None assert updated_task.status == "input_required" # Get the queued message and set cancellation exception on its resolver - msg = await queue.dequeue(task.taskId) + msg = await queue.dequeue(task.task_id) assert msg is not None assert msg.resolver is not None @@ -559,7 +559,7 @@ async def do_elicit() -> None: msg.resolver.set_exception(asyncio.CancelledError()) # Verify task is back to working after cancellation - final_task = await store.get_task(task.taskId) + final_task = await store.get_task(task.task_id) assert final_task is not None assert final_task.status == "working" assert cancelled_error_raised @@ -612,15 +612,15 @@ async def do_sampling() -> None: tg.start_soon(do_sampling) # Wait for request to be queued - await queue.wait_for_message(task.taskId) + await queue.wait_for_message(task.task_id) # Verify task is in input_required status - updated_task = await store.get_task(task.taskId) + updated_task = await store.get_task(task.task_id) assert updated_task is not None assert updated_task.status == "input_required" # Get the queued message and set cancellation exception on its resolver - msg = await queue.dequeue(task.taskId) + msg = await queue.dequeue(task.task_id) assert msg is not None assert msg.resolver is not None @@ -628,7 +628,7 @@ async def do_sampling() -> None: msg.resolver.set_exception(asyncio.CancelledError()) # Verify task is back to working after cancellation - final_task = await store.get_task(task.taskId) + final_task = await store.get_task(task.task_id) assert final_task is not None assert final_task.status == "working" assert cancelled_error_raised @@ -646,7 +646,7 @@ async def test_elicit_as_task_raises_without_handler() -> None: # Create mock session with proper client capabilities mock_session = Mock() mock_session.client_params = InitializeRequestParams( - protocolVersion="2025-01-01", + protocol_version="2025-01-01", capabilities=ClientCapabilities( tasks=ClientTasksCapability( requests=ClientTasksRequestsCapability( @@ -654,7 +654,7 @@ async def test_elicit_as_task_raises_without_handler() -> None: ) ) ), - clientInfo=Implementation(name="test", version="1.0"), + client_info=Implementation(name="test", version="1.0"), ) ctx = ServerTaskContext( @@ -666,7 +666,7 @@ async def test_elicit_as_task_raises_without_handler() -> None: ) with pytest.raises(RuntimeError, match="handler is required for elicit_as_task"): - await ctx.elicit_as_task(message="Test?", requestedSchema={"type": "object"}) + await ctx.elicit_as_task(message="Test?", requested_schema={"type": "object"}) store.cleanup() @@ -681,15 +681,15 @@ async def test_create_message_as_task_raises_without_handler() -> None: # Create mock session with proper client capabilities mock_session = Mock() mock_session.client_params = InitializeRequestParams( - protocolVersion="2025-01-01", + protocol_version="2025-01-01", capabilities=ClientCapabilities( tasks=ClientTasksCapability( requests=ClientTasksRequestsCapability( - sampling=TasksSamplingCapability(createMessage=TasksCreateMessageCapability()) + sampling=TasksSamplingCapability(create_message=TasksCreateMessageCapability()) ) ) ), - clientInfo=Implementation(name="test", version="1.0"), + client_info=Implementation(name="test", version="1.0"), ) ctx = ServerTaskContext( diff --git a/tests/experimental/tasks/server/test_store.py b/tests/experimental/tasks/server/test_store.py index 2eac31dfe..0d431899c 100644 --- a/tests/experimental/tasks/server/test_store.py +++ b/tests/experimental/tasks/server/test_store.py @@ -5,7 +5,7 @@ import pytest -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.shared.experimental.tasks.helpers import cancel_task from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore from mcp.types import INVALID_PARAMS, CallToolResult, TaskMetadata, TextContent @@ -24,13 +24,13 @@ async def test_create_and_get(store: InMemoryTaskStore) -> None: """Test InMemoryTaskStore create and get operations.""" task = await store.create_task(metadata=TaskMetadata(ttl=60000)) - assert task.taskId is not None + assert task.task_id is not None assert task.status == "working" assert task.ttl == 60000 - retrieved = await store.get_task(task.taskId) + retrieved = await store.get_task(task.task_id) assert retrieved is not None - assert retrieved.taskId == task.taskId + assert retrieved.task_id == task.task_id assert retrieved.status == "working" @@ -42,12 +42,12 @@ async def test_create_with_custom_id(store: InMemoryTaskStore) -> None: task_id="my-custom-id", ) - assert task.taskId == "my-custom-id" + assert task.task_id == "my-custom-id" assert task.status == "working" retrieved = await store.get_task("my-custom-id") assert retrieved is not None - assert retrieved.taskId == "my-custom-id" + assert retrieved.task_id == "my-custom-id" @pytest.mark.anyio @@ -71,15 +71,15 @@ async def test_update_status(store: InMemoryTaskStore) -> None: """Test InMemoryTaskStore status updates.""" task = await store.create_task(metadata=TaskMetadata(ttl=60000)) - updated = await store.update_task(task.taskId, status="completed", status_message="All done!") + updated = await store.update_task(task.task_id, status="completed", status_message="All done!") assert updated.status == "completed" - assert updated.statusMessage == "All done!" + assert updated.status_message == "All done!" - retrieved = await store.get_task(task.taskId) + retrieved = await store.get_task(task.task_id) assert retrieved is not None assert retrieved.status == "completed" - assert retrieved.statusMessage == "All done!" + assert retrieved.status_message == "All done!" @pytest.mark.anyio @@ -96,10 +96,10 @@ async def test_store_and_get_result(store: InMemoryTaskStore) -> None: # Store result result = CallToolResult(content=[TextContent(type="text", text="Result data")]) - await store.store_result(task.taskId, result) + await store.store_result(task.task_id, result) # Retrieve result - retrieved_result = await store.get_result(task.taskId) + retrieved_result = await store.get_result(task.task_id) assert retrieved_result == result @@ -114,7 +114,7 @@ async def test_get_result_nonexistent_returns_none(store: InMemoryTaskStore) -> async def test_get_result_no_result_returns_none(store: InMemoryTaskStore) -> None: """Test that getting result when none stored returns None.""" task = await store.create_task(metadata=TaskMetadata(ttl=60000)) - result = await store.get_result(task.taskId) + result = await store.get_result(task.task_id) assert result is None @@ -172,14 +172,14 @@ async def test_delete_task(store: InMemoryTaskStore) -> None: """Test InMemoryTaskStore delete operation.""" task = await store.create_task(metadata=TaskMetadata(ttl=60000)) - deleted = await store.delete_task(task.taskId) + deleted = await store.delete_task(task.task_id) assert deleted is True - retrieved = await store.get_task(task.taskId) + retrieved = await store.get_task(task.task_id) assert retrieved is None # Delete non-existent - deleted = await store.delete_task(task.taskId) + deleted = await store.delete_task(task.task_id) assert deleted is False @@ -210,7 +210,7 @@ async def test_create_task_with_null_ttl(store: InMemoryTaskStore) -> None: assert task.ttl is None # Task should persist (not expire) - retrieved = await store.get_task(task.taskId) + retrieved = await store.get_task(task.task_id) assert retrieved is not None @@ -221,19 +221,19 @@ async def test_task_expiration_cleanup(store: InMemoryTaskStore) -> None: task = await store.create_task(metadata=TaskMetadata(ttl=1)) # 1ms TTL # Manually force the expiry to be in the past - stored = store._tasks.get(task.taskId) + stored = store._tasks.get(task.task_id) assert stored is not None stored.expires_at = datetime.now(timezone.utc) - timedelta(seconds=10) # Task should still exist in internal dict but be expired - assert task.taskId in store._tasks + assert task.task_id in store._tasks # Any access operation should clean up expired tasks # list_tasks triggers cleanup tasks, _ = await store.list_tasks() # Expired task should be cleaned up - assert task.taskId not in store._tasks + assert task.task_id not in store._tasks assert len(tasks) == 0 @@ -244,17 +244,17 @@ async def test_task_with_null_ttl_never_expires(store: InMemoryTaskStore) -> Non task = await store.create_task(metadata=TaskMetadata(ttl=None)) # Verify internal storage has no expiry - stored = store._tasks.get(task.taskId) + stored = store._tasks.get(task.task_id) assert stored is not None assert stored.expires_at is None # Access operations should NOT remove this task await store.list_tasks() - await store.get_task(task.taskId) + await store.get_task(task.task_id) # Task should still exist - assert task.taskId in store._tasks - retrieved = await store.get_task(task.taskId) + assert task.task_id in store._tasks + retrieved = await store.get_task(task.task_id) assert retrieved is not None @@ -265,13 +265,13 @@ async def test_terminal_task_ttl_reset(store: InMemoryTaskStore) -> None: task = await store.create_task(metadata=TaskMetadata(ttl=60000)) # 60s # Get the initial expiry - stored = store._tasks.get(task.taskId) + stored = store._tasks.get(task.task_id) assert stored is not None initial_expiry = stored.expires_at assert initial_expiry is not None # Update to terminal state (completed) - await store.update_task(task.taskId, status="completed") + await store.update_task(task.task_id, status="completed") # Expiry should be reset to a new time (from now + TTL) new_expiry = stored.expires_at @@ -291,16 +291,16 @@ async def test_terminal_status_transition_rejected(store: InMemoryTaskStore) -> task = await store.create_task(metadata=TaskMetadata(ttl=60000)) # Move to terminal state - await store.update_task(task.taskId, status=terminal_status) + await store.update_task(task.task_id, status=terminal_status) # Attempting to transition to any other status should raise with pytest.raises(ValueError, match="Cannot transition from terminal status"): - await store.update_task(task.taskId, status="working") + await store.update_task(task.task_id, status="working") # Also test transitioning to another terminal state other_terminal = "failed" if terminal_status != "failed" else "completed" with pytest.raises(ValueError, match="Cannot transition from terminal status"): - await store.update_task(task.taskId, status=other_terminal) + await store.update_task(task.task_id, status=other_terminal) @pytest.mark.anyio @@ -310,15 +310,15 @@ async def test_terminal_status_allows_same_status(store: InMemoryTaskStore) -> N This is not a transition, so it should be allowed (no-op). """ task = await store.create_task(metadata=TaskMetadata(ttl=60000)) - await store.update_task(task.taskId, status="completed") + await store.update_task(task.task_id, status="completed") # Setting the same status should not raise - updated = await store.update_task(task.taskId, status="completed") + updated = await store.update_task(task.task_id, status="completed") assert updated.status == "completed" # Updating just the message should also work - updated = await store.update_task(task.taskId, status_message="Updated message") - assert updated.statusMessage == "Updated message" + updated = await store.update_task(task.task_id, status_message="Updated message") + assert updated.status_message == "Updated message" @pytest.mark.anyio @@ -334,21 +334,21 @@ async def test_cancel_task_succeeds_for_working_task(store: InMemoryTaskStore) - task = await store.create_task(metadata=TaskMetadata(ttl=60000)) assert task.status == "working" - result = await cancel_task(store, task.taskId) + result = await cancel_task(store, task.task_id) - assert result.taskId == task.taskId + assert result.task_id == task.task_id assert result.status == "cancelled" # Verify store is updated - retrieved = await store.get_task(task.taskId) + retrieved = await store.get_task(task.task_id) assert retrieved is not None assert retrieved.status == "cancelled" @pytest.mark.anyio async def test_cancel_task_rejects_nonexistent_task(store: InMemoryTaskStore) -> None: - """Test cancel_task raises McpError with INVALID_PARAMS for nonexistent task.""" - with pytest.raises(McpError) as exc_info: + """Test cancel_task raises MCPError with INVALID_PARAMS for nonexistent task.""" + with pytest.raises(MCPError) as exc_info: await cancel_task(store, "nonexistent-task-id") assert exc_info.value.error.code == INVALID_PARAMS @@ -357,12 +357,12 @@ async def test_cancel_task_rejects_nonexistent_task(store: InMemoryTaskStore) -> @pytest.mark.anyio async def test_cancel_task_rejects_completed_task(store: InMemoryTaskStore) -> None: - """Test cancel_task raises McpError with INVALID_PARAMS for completed task.""" + """Test cancel_task raises MCPError with INVALID_PARAMS for completed task.""" task = await store.create_task(metadata=TaskMetadata(ttl=60000)) - await store.update_task(task.taskId, status="completed") + await store.update_task(task.task_id, status="completed") - with pytest.raises(McpError) as exc_info: - await cancel_task(store, task.taskId) + with pytest.raises(MCPError) as exc_info: + await cancel_task(store, task.task_id) assert exc_info.value.error.code == INVALID_PARAMS assert "terminal state 'completed'" in exc_info.value.error.message @@ -370,12 +370,12 @@ async def test_cancel_task_rejects_completed_task(store: InMemoryTaskStore) -> N @pytest.mark.anyio async def test_cancel_task_rejects_failed_task(store: InMemoryTaskStore) -> None: - """Test cancel_task raises McpError with INVALID_PARAMS for failed task.""" + """Test cancel_task raises MCPError with INVALID_PARAMS for failed task.""" task = await store.create_task(metadata=TaskMetadata(ttl=60000)) - await store.update_task(task.taskId, status="failed") + await store.update_task(task.task_id, status="failed") - with pytest.raises(McpError) as exc_info: - await cancel_task(store, task.taskId) + with pytest.raises(MCPError) as exc_info: + await cancel_task(store, task.task_id) assert exc_info.value.error.code == INVALID_PARAMS assert "terminal state 'failed'" in exc_info.value.error.message @@ -383,12 +383,12 @@ async def test_cancel_task_rejects_failed_task(store: InMemoryTaskStore) -> None @pytest.mark.anyio async def test_cancel_task_rejects_already_cancelled_task(store: InMemoryTaskStore) -> None: - """Test cancel_task raises McpError with INVALID_PARAMS for already cancelled task.""" + """Test cancel_task raises MCPError with INVALID_PARAMS for already cancelled task.""" task = await store.create_task(metadata=TaskMetadata(ttl=60000)) - await store.update_task(task.taskId, status="cancelled") + await store.update_task(task.task_id, status="cancelled") - with pytest.raises(McpError) as exc_info: - await cancel_task(store, task.taskId) + with pytest.raises(MCPError) as exc_info: + await cancel_task(store, task.task_id) assert exc_info.value.error.code == INVALID_PARAMS assert "terminal state 'cancelled'" in exc_info.value.error.message @@ -398,9 +398,9 @@ async def test_cancel_task_rejects_already_cancelled_task(store: InMemoryTaskSto async def test_cancel_task_succeeds_for_input_required_task(store: InMemoryTaskStore) -> None: """Test cancel_task helper succeeds for a task in input_required status.""" task = await store.create_task(metadata=TaskMetadata(ttl=60000)) - await store.update_task(task.taskId, status="input_required") + await store.update_task(task.task_id, status="input_required") - result = await cancel_task(store, task.taskId) + result = await cancel_task(store, task.task_id) - assert result.taskId == task.taskId + assert result.task_id == task.task_id assert result.status == "cancelled" diff --git a/tests/experimental/tasks/server/test_task_result_handler.py b/tests/experimental/tasks/server/test_task_result_handler.py index db5b9edc7..8b5a03ce2 100644 --- a/tests/experimental/tasks/server/test_task_result_handler.py +++ b/tests/experimental/tasks/server/test_task_result_handler.py @@ -8,7 +8,7 @@ import pytest from mcp.server.experimental.task_result_handler import TaskResultHandler -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore from mcp.shared.experimental.tasks.message_queue import InMemoryTaskMessageQueue, QueuedMessage from mcp.shared.experimental.tasks.resolver import Resolver @@ -53,13 +53,13 @@ async def test_handle_returns_result_for_completed_task( """Test that handle() returns the stored result for a completed task.""" task = await store.create_task(TaskMetadata(ttl=60000), task_id="test-task") result = CallToolResult(content=[TextContent(type="text", text="Done!")]) - await store.store_result(task.taskId, result) - await store.update_task(task.taskId, status="completed") + await store.store_result(task.task_id, result) + await store.update_task(task.task_id, status="completed") mock_session = Mock() mock_session.send_message = AsyncMock() - request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId=task.taskId)) + request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(task_id=task.task_id)) response = await handler.handle(request, mock_session, "req-1") assert response is not None @@ -71,11 +71,11 @@ async def test_handle_returns_result_for_completed_task( async def test_handle_raises_for_nonexistent_task( store: InMemoryTaskStore, queue: InMemoryTaskMessageQueue, handler: TaskResultHandler ) -> None: - """Test that handle() raises McpError for nonexistent task.""" + """Test that handle() raises MCPError for nonexistent task.""" mock_session = Mock() - request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId="nonexistent")) + request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(task_id="nonexistent")) - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: await handler.handle(request, mock_session, "req-1") assert "not found" in exc_info.value.error.message @@ -87,12 +87,12 @@ async def test_handle_returns_empty_result_when_no_result_stored( ) -> None: """Test that handle() returns minimal result when task completed without stored result.""" task = await store.create_task(TaskMetadata(ttl=60000), task_id="test-task") - await store.update_task(task.taskId, status="completed") + await store.update_task(task.task_id, status="completed") mock_session = Mock() mock_session.send_message = AsyncMock() - request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId=task.taskId)) + request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(task_id=task.task_id)) response = await handler.handle(request, mock_session, "req-1") assert response is not None @@ -116,8 +116,8 @@ async def test_handle_delivers_queued_messages( params={}, ), ) - await queue.enqueue(task.taskId, queued_msg) - await store.update_task(task.taskId, status="completed") + await queue.enqueue(task.task_id, queued_msg) + await store.update_task(task.task_id, status="completed") sent_messages: list[SessionMessage] = [] @@ -127,7 +127,7 @@ async def track_send(msg: SessionMessage) -> None: mock_session = Mock() mock_session.send_message = track_send - request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId=task.taskId)) + request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(task_id=task.task_id)) await handler.handle(request, mock_session, "req-1") assert len(sent_messages) == 1 @@ -143,7 +143,7 @@ async def test_handle_waits_for_task_completion( mock_session = Mock() mock_session.send_message = AsyncMock() - request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(taskId=task.taskId)) + request = GetTaskPayloadRequest(params=GetTaskPayloadRequestParams(task_id=task.task_id)) result_holder: list[GetTaskPayloadResult | None] = [None] async def run_handle() -> None: @@ -153,11 +153,11 @@ async def run_handle() -> None: tg.start_soon(run_handle) # Wait for handler to start waiting (event gets created when wait starts) - while task.taskId not in store._update_events: + while task.task_id not in store._update_events: await anyio.sleep(0) - await store.store_result(task.taskId, CallToolResult(content=[TextContent(type="text", text="Done")])) - await store.update_task(task.taskId, status="completed") + await store.store_result(task.task_id, CallToolResult(content=[TextContent(type="text", text="Done")])) + await store.update_task(task.task_id, status="completed") assert result_holder[0] is not None @@ -214,7 +214,7 @@ async def test_route_error_resolves_pending_request_with_exception( assert result is True assert resolver.done() - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: await resolver.wait() assert exc_info.value.error.message == "Something went wrong" @@ -248,12 +248,12 @@ async def test_deliver_registers_resolver_for_request_messages( resolver=resolver, original_request_id="inner-req-1", ) - await queue.enqueue(task.taskId, queued_msg) + await queue.enqueue(task.task_id, queued_msg) mock_session = Mock() mock_session.send_message = AsyncMock() - await handler._deliver_queued_messages(task.taskId, mock_session, "outer-req-1") + await handler._deliver_queued_messages(task.task_id, mock_session, "outer-req-1") assert "inner-req-1" in handler._pending_requests assert handler._pending_requests["inner-req-1"] is resolver @@ -278,12 +278,12 @@ async def test_deliver_skips_resolver_registration_when_no_original_id( resolver=resolver, original_request_id=None, # No original request ID ) - await queue.enqueue(task.taskId, queued_msg) + await queue.enqueue(task.task_id, queued_msg) mock_session = Mock() mock_session.send_message = AsyncMock() - await handler._deliver_queued_messages(task.taskId, mock_session, "outer-req-1") + await handler._deliver_queued_messages(task.task_id, mock_session, "outer-req-1") # Resolver should NOT be registered since original_request_id is None assert len(handler._pending_requests) == 0 @@ -307,10 +307,10 @@ async def failing_wait(task_id: str) -> None: # Queue a message to unblock the race via the queue path async def enqueue_later() -> None: # Wait for queue to start waiting (event gets created when wait starts) - while task.taskId not in queue._events: + while task.task_id not in queue._events: await anyio.sleep(0) await queue.enqueue( - task.taskId, + task.task_id, QueuedMessage( type="notification", message=JSONRPCRequest( @@ -325,7 +325,7 @@ async def enqueue_later() -> None: async with anyio.create_task_group() as tg: tg.start_soon(enqueue_later) # This should complete via the queue path even though store raises - await handler._wait_for_task_update(task.taskId) + await handler._wait_for_task_update(task.task_id) @pytest.mark.anyio @@ -344,11 +344,11 @@ async def failing_wait(task_id: str) -> None: # Update the store to unblock the race via the store path async def update_later() -> None: # Wait for store to start waiting (event gets created when wait starts) - while task.taskId not in store._update_events: + while task.task_id not in store._update_events: await anyio.sleep(0) - await store.update_task(task.taskId, status="completed") + await store.update_task(task.task_id, status="completed") async with anyio.create_task_group() as tg: tg.start_soon(update_later) # This should complete via the store path even though queue raises - await handler._wait_for_task_update(task.taskId) + await handler._wait_for_task_update(task.task_id) diff --git a/tests/experimental/tasks/test_capabilities.py b/tests/experimental/tasks/test_capabilities.py index e78f16fe3..90a8656ba 100644 --- a/tests/experimental/tasks/test_capabilities.py +++ b/tests/experimental/tasks/test_capabilities.py @@ -2,7 +2,7 @@ import pytest -from mcp.shared.exceptions import McpError +from mcp import MCPError from mcp.shared.experimental.tasks.capabilities import ( check_tasks_capability, has_task_augmented_elicitation, @@ -82,7 +82,7 @@ def test_sampling_create_message_required_but_client_missing(self) -> None: """When sampling.createMessage is required but client doesn't have it.""" required = ClientTasksCapability( requests=ClientTasksRequestsCapability( - sampling=TasksSamplingCapability(createMessage=TasksCreateMessageCapability()) + sampling=TasksSamplingCapability(create_message=TasksCreateMessageCapability()) ) ) client = ClientTasksCapability( @@ -96,12 +96,12 @@ def test_sampling_create_message_present(self) -> None: """When sampling.createMessage is required and client has it.""" required = ClientTasksCapability( requests=ClientTasksRequestsCapability( - sampling=TasksSamplingCapability(createMessage=TasksCreateMessageCapability()) + sampling=TasksSamplingCapability(create_message=TasksCreateMessageCapability()) ) ) client = ClientTasksCapability( requests=ClientTasksRequestsCapability( - sampling=TasksSamplingCapability(createMessage=TasksCreateMessageCapability()) + sampling=TasksSamplingCapability(create_message=TasksCreateMessageCapability()) ) ) assert check_tasks_capability(required, client) is True @@ -111,13 +111,13 @@ def test_both_elicitation_and_sampling_present(self) -> None: required = ClientTasksCapability( requests=ClientTasksRequestsCapability( elicitation=TasksElicitationCapability(create=TasksCreateElicitationCapability()), - sampling=TasksSamplingCapability(createMessage=TasksCreateMessageCapability()), + sampling=TasksSamplingCapability(create_message=TasksCreateMessageCapability()), ) ) client = ClientTasksCapability( requests=ClientTasksRequestsCapability( elicitation=TasksElicitationCapability(create=TasksCreateElicitationCapability()), - sampling=TasksSamplingCapability(createMessage=TasksCreateMessageCapability()), + sampling=TasksSamplingCapability(create_message=TasksCreateMessageCapability()), ) ) assert check_tasks_capability(required, client) is True @@ -145,7 +145,7 @@ def test_sampling_without_create_message_required(self) -> None: ) client = ClientTasksCapability( requests=ClientTasksRequestsCapability( - sampling=TasksSamplingCapability(createMessage=TasksCreateMessageCapability()) + sampling=TasksSamplingCapability(create_message=TasksCreateMessageCapability()) ) ) assert check_tasks_capability(required, client) is True @@ -220,7 +220,7 @@ def test_create_message_present(self) -> None: caps = ClientCapabilities( tasks=ClientTasksCapability( requests=ClientTasksRequestsCapability( - sampling=TasksSamplingCapability(createMessage=TasksCreateMessageCapability()) + sampling=TasksSamplingCapability(create_message=TasksCreateMessageCapability()) ) ) ) @@ -231,15 +231,15 @@ class TestRequireTaskAugmentedElicitation: """Tests for require_task_augmented_elicitation function.""" def test_raises_when_none(self) -> None: - """Raises McpError when client_caps is None.""" - with pytest.raises(McpError) as exc_info: + """Raises MCPError when client_caps is None.""" + with pytest.raises(MCPError) as exc_info: require_task_augmented_elicitation(None) assert "task-augmented elicitation" in str(exc_info.value) def test_raises_when_missing(self) -> None: - """Raises McpError when capability is missing.""" + """Raises MCPError when capability is missing.""" caps = ClientCapabilities() - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: require_task_augmented_elicitation(caps) assert "task-augmented elicitation" in str(exc_info.value) @@ -259,15 +259,15 @@ class TestRequireTaskAugmentedSampling: """Tests for require_task_augmented_sampling function.""" def test_raises_when_none(self) -> None: - """Raises McpError when client_caps is None.""" - with pytest.raises(McpError) as exc_info: + """Raises MCPError when client_caps is None.""" + with pytest.raises(MCPError) as exc_info: require_task_augmented_sampling(None) assert "task-augmented sampling" in str(exc_info.value) def test_raises_when_missing(self) -> None: - """Raises McpError when capability is missing.""" + """Raises MCPError when capability is missing.""" caps = ClientCapabilities() - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: require_task_augmented_sampling(caps) assert "task-augmented sampling" in str(exc_info.value) @@ -276,7 +276,7 @@ def test_passes_when_present(self) -> None: caps = ClientCapabilities( tasks=ClientTasksCapability( requests=ClientTasksRequestsCapability( - sampling=TasksSamplingCapability(createMessage=TasksCreateMessageCapability()) + sampling=TasksSamplingCapability(create_message=TasksCreateMessageCapability()) ) ) ) diff --git a/tests/experimental/tasks/test_elicitation_scenarios.py b/tests/experimental/tasks/test_elicitation_scenarios.py index be2b61601..2d0378a9c 100644 --- a/tests/experimental/tasks/test_elicitation_scenarios.py +++ b/tests/experimental/tasks/test_elicitation_scenarios.py @@ -1,5 +1,4 @@ -""" -Tests for the four elicitation scenarios with tasks. +"""Tests for the four elicitation scenarios with tasks. This tests all combinations of tool call types and elicitation types: 1. Normal tool call + Normal elicitation (session.elicit) @@ -18,15 +17,16 @@ from mcp.client.experimental.task_handlers import ExperimentalTaskHandlers from mcp.client.session import ClientSession -from mcp.server import Server +from mcp.server import Server, ServerRequestContext from mcp.server.experimental.task_context import ServerTaskContext from mcp.server.lowlevel import NotificationOptions -from mcp.shared.context import RequestContext +from mcp.shared._context import RequestContext from mcp.shared.experimental.tasks.helpers import is_terminal from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore from mcp.shared.message import SessionMessage from mcp.types import ( TASK_REQUIRED, + CallToolRequestParams, CallToolResult, CreateMessageRequestParams, CreateMessageResult, @@ -36,11 +36,12 @@ ErrorData, GetTaskPayloadResult, GetTaskResult, + ListToolsResult, + PaginatedRequestParams, SamplingMessage, TaskMetadata, TextContent, Tool, - ToolExecution, ) @@ -54,51 +55,51 @@ def create_client_task_handlers( task_complete_events: dict[str, Event] = {} async def handle_augmented_elicitation( - context: RequestContext[ClientSession, Any], + context: RequestContext[ClientSession], params: ElicitRequestParams, task_metadata: TaskMetadata, ) -> CreateTaskResult: """Handle task-augmented elicitation by creating a client-side task.""" elicit_received.set() task = await client_task_store.create_task(task_metadata) - task_complete_events[task.taskId] = Event() + task_complete_events[task.task_id] = Event() async def complete_task() -> None: # Store result before updating status to avoid race condition - await client_task_store.store_result(task.taskId, elicit_response) - await client_task_store.update_task(task.taskId, status="completed") - task_complete_events[task.taskId].set() + await client_task_store.store_result(task.task_id, elicit_response) + await client_task_store.update_task(task.task_id, status="completed") + task_complete_events[task.task_id].set() context.session._task_group.start_soon(complete_task) # pyright: ignore[reportPrivateUsage] return CreateTaskResult(task=task) async def handle_get_task( - context: RequestContext[ClientSession, Any], + context: RequestContext[ClientSession], params: Any, ) -> GetTaskResult: """Handle tasks/get from server.""" - task = await client_task_store.get_task(params.taskId) - assert task is not None, f"Task not found: {params.taskId}" + task = await client_task_store.get_task(params.task_id) + assert task is not None, f"Task not found: {params.task_id}" return GetTaskResult( - taskId=task.taskId, + task_id=task.task_id, status=task.status, - statusMessage=task.statusMessage, - createdAt=task.createdAt, - lastUpdatedAt=task.lastUpdatedAt, + status_message=task.status_message, + created_at=task.created_at, + last_updated_at=task.last_updated_at, ttl=task.ttl, - pollInterval=100, + poll_interval=100, ) async def handle_get_task_result( - context: RequestContext[ClientSession, Any], + context: RequestContext[ClientSession], params: Any, ) -> GetTaskPayloadResult | ErrorData: """Handle tasks/result from server.""" - event = task_complete_events.get(params.taskId) - assert event is not None, f"No completion event for task: {params.taskId}" + event = task_complete_events.get(params.task_id) + assert event is not None, f"No completion event for task: {params.task_id}" await event.wait() - result = await client_task_store.get_result(params.taskId) - assert result is not None, f"Result not found for task: {params.taskId}" + result = await client_task_store.get_result(params.task_id) + assert result is not None, f"Result not found for task: {params.task_id}" return GetTaskPayloadResult.model_validate(result.model_dump(by_alias=True)) return ExperimentalTaskHandlers( @@ -122,51 +123,51 @@ def create_sampling_task_handlers( task_complete_events: dict[str, Event] = {} async def handle_augmented_sampling( - context: RequestContext[ClientSession, Any], + context: RequestContext[ClientSession], params: CreateMessageRequestParams, task_metadata: TaskMetadata, ) -> CreateTaskResult: """Handle task-augmented sampling by creating a client-side task.""" sampling_received.set() task = await client_task_store.create_task(task_metadata) - task_complete_events[task.taskId] = Event() + task_complete_events[task.task_id] = Event() async def complete_task() -> None: # Store result before updating status to avoid race condition - await client_task_store.store_result(task.taskId, sampling_response) - await client_task_store.update_task(task.taskId, status="completed") - task_complete_events[task.taskId].set() + await client_task_store.store_result(task.task_id, sampling_response) + await client_task_store.update_task(task.task_id, status="completed") + task_complete_events[task.task_id].set() context.session._task_group.start_soon(complete_task) # pyright: ignore[reportPrivateUsage] return CreateTaskResult(task=task) async def handle_get_task( - context: RequestContext[ClientSession, Any], + context: RequestContext[ClientSession], params: Any, ) -> GetTaskResult: """Handle tasks/get from server.""" - task = await client_task_store.get_task(params.taskId) - assert task is not None, f"Task not found: {params.taskId}" + task = await client_task_store.get_task(params.task_id) + assert task is not None, f"Task not found: {params.task_id}" return GetTaskResult( - taskId=task.taskId, + task_id=task.task_id, status=task.status, - statusMessage=task.statusMessage, - createdAt=task.createdAt, - lastUpdatedAt=task.lastUpdatedAt, + status_message=task.status_message, + created_at=task.created_at, + last_updated_at=task.last_updated_at, ttl=task.ttl, - pollInterval=100, + poll_interval=100, ) async def handle_get_task_result( - context: RequestContext[ClientSession, Any], + context: RequestContext[ClientSession], params: Any, ) -> GetTaskPayloadResult | ErrorData: """Handle tasks/result from server.""" - event = task_complete_events.get(params.taskId) - assert event is not None, f"No completion event for task: {params.taskId}" + event = task_complete_events.get(params.task_id) + assert event is not None, f"No completion event for task: {params.task_id}" await event.wait() - result = await client_task_store.get_result(params.taskId) - assert result is not None, f"Result not found for task: {params.taskId}" + result = await client_task_store.get_result(params.task_id) + assert result is not None, f"Result not found for task: {params.task_id}" return GetTaskPayloadResult.model_validate(result.model_dump(by_alias=True)) return ExperimentalTaskHandlers( @@ -178,42 +179,40 @@ async def handle_get_task_result( @pytest.mark.anyio async def test_scenario1_normal_tool_normal_elicitation() -> None: - """ - Scenario 1: Normal tool call with normal elicitation. + """Scenario 1: Normal tool call with normal elicitation. Server calls session.elicit() directly, client responds immediately. """ - server = Server("test-scenario1") elicit_received = Event() tool_result: list[str] = [] - @server.list_tools() - async def list_tools() -> list[Tool]: - return [ - Tool( - name="confirm_action", - description="Confirm an action", - inputSchema={"type": "object"}, - ) - ] - - @server.call_tool() - async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult: - ctx = server.request_context + async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult( + tools=[ + Tool( + name="confirm_action", + description="Confirm an action", + input_schema={"type": "object"}, + ) + ] + ) + async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: # Normal elicitation - expects immediate response result = await ctx.session.elicit( message="Please confirm the action", - requestedSchema={"type": "object", "properties": {"confirm": {"type": "boolean"}}}, + requested_schema={"type": "object", "properties": {"confirm": {"type": "boolean"}}}, ) confirmed = result.content.get("confirm", False) if result.content else False tool_result.append("confirmed" if confirmed else "cancelled") return CallToolResult(content=[TextContent(type="text", text="confirmed" if confirmed else "cancelled")]) + server = Server("test-scenario1", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool) + # Elicitation callback for client async def elicitation_callback( - context: RequestContext[ClientSession, Any], + context: RequestContext[ClientSession], params: ElicitRequestParams, ) -> ElicitResult: elicit_received.set() @@ -259,37 +258,33 @@ async def run_client() -> None: @pytest.mark.anyio async def test_scenario2_normal_tool_task_augmented_elicitation() -> None: - """ - Scenario 2: Normal tool call with task-augmented elicitation. + """Scenario 2: Normal tool call with task-augmented elicitation. Server calls session.experimental.elicit_as_task(), client creates a task for the elicitation and returns CreateTaskResult. Server polls client. """ - server = Server("test-scenario2") elicit_received = Event() tool_result: list[str] = [] # Client-side task store for handling task-augmented elicitation client_task_store = InMemoryTaskStore() - @server.list_tools() - async def list_tools() -> list[Tool]: - return [ - Tool( - name="confirm_action", - description="Confirm an action", - inputSchema={"type": "object"}, - ) - ] - - @server.call_tool() - async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult: - ctx = server.request_context + async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult( + tools=[ + Tool( + name="confirm_action", + description="Confirm an action", + input_schema={"type": "object"}, + ) + ] + ) + async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: # Task-augmented elicitation - server polls client result = await ctx.session.experimental.elicit_as_task( message="Please confirm the action", - requestedSchema={"type": "object", "properties": {"confirm": {"type": "boolean"}}}, + requested_schema={"type": "object", "properties": {"confirm": {"type": "boolean"}}}, ttl=60000, ) @@ -297,6 +292,7 @@ async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResu tool_result.append("confirmed" if confirmed else "cancelled") return CallToolResult(content=[TextContent(type="text", text="confirmed" if confirmed else "cancelled")]) + server = Server("test-scenario2", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool) task_handlers = create_client_task_handlers(client_task_store, elicit_received) # Set up streams @@ -340,39 +336,25 @@ async def run_client() -> None: @pytest.mark.anyio async def test_scenario3_task_augmented_tool_normal_elicitation() -> None: - """ - Scenario 3: Task-augmented tool call with normal elicitation. + """Scenario 3: Task-augmented tool call with normal elicitation. Client calls tool as task. Inside the task, server uses task.elicit() which queues the request and delivers via tasks/result. """ - server = Server("test-scenario3") - server.experimental.enable_tasks() - elicit_received = Event() work_completed = Event() - @server.list_tools() - async def list_tools() -> list[Tool]: - return [ - Tool( - name="confirm_action", - description="Confirm an action", - inputSchema={"type": "object"}, - execution=ToolExecution(taskSupport=TASK_REQUIRED), - ) - ] + async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + raise NotImplementedError - @server.call_tool() - async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CreateTaskResult: - ctx = server.request_context + async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CreateTaskResult: ctx.experimental.validate_task_mode(TASK_REQUIRED) async def work(task: ServerTaskContext) -> CallToolResult: # Normal elicitation within task - queued and delivered via tasks/result result = await task.elicit( message="Please confirm the action", - requestedSchema={"type": "object", "properties": {"confirm": {"type": "boolean"}}}, + requested_schema={"type": "object", "properties": {"confirm": {"type": "boolean"}}}, ) confirmed = result.content.get("confirm", False) if result.content else False @@ -381,9 +363,12 @@ async def work(task: ServerTaskContext) -> CallToolResult: return await ctx.experimental.run_task(work) + server = Server("test-scenario3", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool) + server.experimental.enable_tasks() + # Elicitation callback for client async def elicitation_callback( - context: RequestContext[ClientSession, Any], + context: RequestContext[ClientSession], params: ElicitRequestParams, ) -> ElicitResult: elicit_received.set() @@ -413,7 +398,7 @@ async def run_client() -> None: # Call tool as task create_result = await client_session.experimental.call_tool_as_task("confirm_action", {}) - task_id = create_result.task.taskId + task_id = create_result.task.task_id assert create_result.task.status == "working" # Poll until input_required, then call tasks/result @@ -442,8 +427,7 @@ async def run_client() -> None: @pytest.mark.anyio async def test_scenario4_task_augmented_tool_task_augmented_elicitation() -> None: - """ - Scenario 4: Task-augmented tool call with task-augmented elicitation. + """Scenario 4: Task-augmented tool call with task-augmented elicitation. Client calls tool as task. Inside the task, server uses task.elicit_as_task() which sends task-augmented elicitation. Client creates its own task for the @@ -457,36 +441,23 @@ async def test_scenario4_task_augmented_tool_task_augmented_elicitation() -> Non 5. Server gets the ElicitResult and completes the tool task 6. Client's tasks/result returns with the CallToolResult """ - server = Server("test-scenario4") - server.experimental.enable_tasks() - elicit_received = Event() work_completed = Event() # Client-side task store for handling task-augmented elicitation client_task_store = InMemoryTaskStore() - @server.list_tools() - async def list_tools() -> list[Tool]: - return [ - Tool( - name="confirm_action", - description="Confirm an action", - inputSchema={"type": "object"}, - execution=ToolExecution(taskSupport=TASK_REQUIRED), - ) - ] + async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + raise NotImplementedError - @server.call_tool() - async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CreateTaskResult: - ctx = server.request_context + async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CreateTaskResult: ctx.experimental.validate_task_mode(TASK_REQUIRED) async def work(task: ServerTaskContext) -> CallToolResult: # Task-augmented elicitation within task - server polls client result = await task.elicit_as_task( message="Please confirm the action", - requestedSchema={"type": "object", "properties": {"confirm": {"type": "boolean"}}}, + requested_schema={"type": "object", "properties": {"confirm": {"type": "boolean"}}}, ttl=60000, ) @@ -496,6 +467,8 @@ async def work(task: ServerTaskContext) -> CallToolResult: return await ctx.experimental.run_task(work) + server = Server("test-scenario4", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool) + server.experimental.enable_tasks() task_handlers = create_client_task_handlers(client_task_store, elicit_received) # Set up streams @@ -522,7 +495,7 @@ async def run_client() -> None: # Call tool as task create_result = await client_session.experimental.call_tool_as_task("confirm_action", {}) - task_id = create_result.task.taskId + task_id = create_result.task.task_id assert create_result.task.status == "working" # Poll until input_required or terminal, then call tasks/result @@ -553,33 +526,29 @@ async def run_client() -> None: @pytest.mark.anyio async def test_scenario2_sampling_normal_tool_task_augmented_sampling() -> None: - """ - Scenario 2 for sampling: Normal tool call with task-augmented sampling. + """Scenario 2 for sampling: Normal tool call with task-augmented sampling. Server calls session.experimental.create_message_as_task(), client creates a task for the sampling and returns CreateTaskResult. Server polls client. """ - server = Server("test-scenario2-sampling") sampling_received = Event() tool_result: list[str] = [] # Client-side task store for handling task-augmented sampling client_task_store = InMemoryTaskStore() - @server.list_tools() - async def list_tools() -> list[Tool]: - return [ - Tool( - name="generate_text", - description="Generate text using sampling", - inputSchema={"type": "object"}, - ) - ] - - @server.call_tool() - async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult: - ctx = server.request_context + async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult( + tools=[ + Tool( + name="generate_text", + description="Generate text using sampling", + input_schema={"type": "object"}, + ) + ] + ) + async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: # Task-augmented sampling - server polls client result = await ctx.session.experimental.create_message_as_task( messages=[SamplingMessage(role="user", content=TextContent(type="text", text="Hello"))], @@ -593,6 +562,7 @@ async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResu tool_result.append(response_text) return CallToolResult(content=[TextContent(type="text", text=response_text)]) + server = Server("test-scenario2-sampling", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool) task_handlers = create_sampling_task_handlers(client_task_store, sampling_received) # Set up streams @@ -636,36 +606,22 @@ async def run_client() -> None: @pytest.mark.anyio async def test_scenario4_sampling_task_augmented_tool_task_augmented_sampling() -> None: - """ - Scenario 4 for sampling: Task-augmented tool call with task-augmented sampling. + """Scenario 4 for sampling: Task-augmented tool call with task-augmented sampling. Client calls tool as task. Inside the task, server uses task.create_message_as_task() which sends task-augmented sampling. Client creates its own task for the sampling, and server polls the client. """ - server = Server("test-scenario4-sampling") - server.experimental.enable_tasks() - sampling_received = Event() work_completed = Event() # Client-side task store for handling task-augmented sampling client_task_store = InMemoryTaskStore() - @server.list_tools() - async def list_tools() -> list[Tool]: - return [ - Tool( - name="generate_text", - description="Generate text using sampling", - inputSchema={"type": "object"}, - execution=ToolExecution(taskSupport=TASK_REQUIRED), - ) - ] + async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + raise NotImplementedError - @server.call_tool() - async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CreateTaskResult: - ctx = server.request_context + async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CreateTaskResult: ctx.experimental.validate_task_mode(TASK_REQUIRED) async def work(task: ServerTaskContext) -> CallToolResult: @@ -684,6 +640,8 @@ async def work(task: ServerTaskContext) -> CallToolResult: return await ctx.experimental.run_task(work) + server = Server("test-scenario4-sampling", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool) + server.experimental.enable_tasks() task_handlers = create_sampling_task_handlers(client_task_store, sampling_received) # Set up streams @@ -710,7 +668,7 @@ async def run_client() -> None: # Call tool as task create_result = await client_session.experimental.call_tool_as_task("generate_text", {}) - task_id = create_result.task.taskId + task_id = create_result.task.task_id assert create_result.task.status == "working" # Poll until input_required or terminal diff --git a/tests/experimental/tasks/test_message_queue.py b/tests/experimental/tasks/test_message_queue.py index 86d6875cc..a8517e535 100644 --- a/tests/experimental/tasks/test_message_queue.py +++ b/tests/experimental/tasks/test_message_queue.py @@ -1,6 +1,4 @@ -""" -Tests for TaskMessageQueue and InMemoryTaskMessageQueue. -""" +"""Tests for TaskMessageQueue and InMemoryTaskMessageQueue.""" from datetime import datetime, timezone diff --git a/tests/experimental/tasks/test_request_context.py b/tests/experimental/tasks/test_request_context.py index 5fa5da81a..ad4023389 100644 --- a/tests/experimental/tasks/test_request_context.py +++ b/tests/experimental/tasks/test_request_context.py @@ -3,7 +3,7 @@ import pytest from mcp.server.experimental.request_context import Experimental -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.types import ( METHOD_NOT_FOUND, TASK_FORBIDDEN, @@ -58,7 +58,7 @@ def test_validate_task_mode_required_without_task_returns_error() -> None: def test_validate_task_mode_required_without_task_raises_by_default() -> None: exp = Experimental(task_metadata=None) - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: exp.validate_task_mode(TASK_REQUIRED) assert exc_info.value.error.code == METHOD_NOT_FOUND @@ -79,7 +79,7 @@ def test_validate_task_mode_forbidden_with_task_returns_error() -> None: def test_validate_task_mode_forbidden_with_task_raises_by_default() -> None: exp = Experimental(task_metadata=TaskMetadata(ttl=60000)) - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: exp.validate_task_mode(TASK_FORBIDDEN) assert exc_info.value.error.code == METHOD_NOT_FOUND @@ -108,8 +108,8 @@ def test_validate_for_tool_with_execution_required() -> None: tool = Tool( name="test", description="test", - inputSchema={"type": "object"}, - execution=ToolExecution(taskSupport=TASK_REQUIRED), + input_schema={"type": "object"}, + execution=ToolExecution(task_support=TASK_REQUIRED), ) error = exp.validate_for_tool(tool, raise_error=False) assert error is not None @@ -121,7 +121,7 @@ def test_validate_for_tool_without_execution() -> None: tool = Tool( name="test", description="test", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, execution=None, ) error = exp.validate_for_tool(tool, raise_error=False) @@ -134,8 +134,8 @@ def test_validate_for_tool_optional_with_task() -> None: tool = Tool( name="test", description="test", - inputSchema={"type": "object"}, - execution=ToolExecution(taskSupport=TASK_OPTIONAL), + input_schema={"type": "object"}, + execution=ToolExecution(task_support=TASK_OPTIONAL), ) error = exp.validate_for_tool(tool, raise_error=False) assert error is None diff --git a/tests/experimental/tasks/test_spec_compliance.py b/tests/experimental/tasks/test_spec_compliance.py index 842bfa7e1..38d7d0a66 100644 --- a/tests/experimental/tasks/test_spec_compliance.py +++ b/tests/experimental/tasks/test_spec_compliance.py @@ -1,5 +1,4 @@ -""" -Tasks Spec Compliance Tests +"""Tasks Spec Compliance Tests =========================== Test structure mirrors: https://modelcontextprotocol.io/specification/draft/basic/utilities/tasks.md @@ -11,17 +10,17 @@ import pytest -from mcp.server import Server +from mcp.server import Server, ServerRequestContext from mcp.server.lowlevel import NotificationOptions from mcp.shared.experimental.tasks.helpers import MODEL_IMMEDIATE_RESPONSE_KEY from mcp.types import ( - CancelTaskRequest, + CancelTaskRequestParams, CancelTaskResult, CreateTaskResult, - GetTaskRequest, + GetTaskRequestParams, GetTaskResult, - ListTasksRequest, ListTasksResult, + PaginatedRequestParams, ServerCapabilities, Task, ) @@ -45,13 +44,22 @@ def test_server_without_task_handlers_has_no_tasks_capability() -> None: assert caps.tasks is None +async def _noop_get_task(ctx: ServerRequestContext, params: GetTaskRequestParams) -> GetTaskResult: + raise NotImplementedError + + +async def _noop_list_tasks(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListTasksResult: + raise NotImplementedError + + +async def _noop_cancel_task(ctx: ServerRequestContext, params: CancelTaskRequestParams) -> CancelTaskResult: + raise NotImplementedError + + def test_server_with_list_tasks_handler_declares_list_capability() -> None: """Server with list_tasks handler declares tasks.list capability.""" server: Server = Server("test") - - @server.experimental.list_tasks() - async def handle_list(req: ListTasksRequest) -> ListTasksResult: - raise NotImplementedError + server.experimental.enable_tasks(on_list_tasks=_noop_list_tasks) caps = _get_capabilities(server) assert caps.tasks is not None @@ -61,10 +69,7 @@ async def handle_list(req: ListTasksRequest) -> ListTasksResult: def test_server_with_cancel_task_handler_declares_cancel_capability() -> None: """Server with cancel_task handler declares tasks.cancel capability.""" server: Server = Server("test") - - @server.experimental.cancel_task() - async def handle_cancel(req: CancelTaskRequest) -> CancelTaskResult: - raise NotImplementedError + server.experimental.enable_tasks(on_cancel_task=_noop_cancel_task) caps = _get_capabilities(server) assert caps.tasks is not None @@ -72,15 +77,11 @@ async def handle_cancel(req: CancelTaskRequest) -> CancelTaskResult: def test_server_with_get_task_handler_declares_requests_tools_call_capability() -> None: - """ - Server with get_task handler declares tasks.requests.tools.call capability. + """Server with get_task handler declares tasks.requests.tools.call capability. (get_task is required for task-augmented tools/call support) """ server: Server = Server("test") - - @server.experimental.get_task() - async def handle_get(req: GetTaskRequest) -> GetTaskResult: - raise NotImplementedError + server.experimental.enable_tasks(on_get_task=_noop_get_task) caps = _get_capabilities(server) assert caps.tasks is not None @@ -88,28 +89,30 @@ async def handle_get(req: GetTaskRequest) -> GetTaskResult: assert caps.tasks.requests.tools is not None -def test_server_without_list_handler_has_no_list_capability() -> None: +@pytest.mark.skip( + reason="TODO(maxisbey): enable_tasks registers default handlers for all task methods, " + "so partial capabilities aren't possible yet. Low-level API should support " + "selectively enabling/disabling task capabilities." +) +def test_server_without_list_handler_has_no_list_capability() -> None: # pragma: no cover """Server without list_tasks handler has no tasks.list capability.""" server: Server = Server("test") - - # Register only get_task (not list_tasks) - @server.experimental.get_task() - async def handle_get(req: GetTaskRequest) -> GetTaskResult: - raise NotImplementedError + server.experimental.enable_tasks(on_get_task=_noop_get_task) caps = _get_capabilities(server) assert caps.tasks is not None assert caps.tasks.list is None -def test_server_without_cancel_handler_has_no_cancel_capability() -> None: +@pytest.mark.skip( + reason="TODO(maxisbey): enable_tasks registers default handlers for all task methods, " + "so partial capabilities aren't possible yet. Low-level API should support " + "selectively enabling/disabling task capabilities." +) +def test_server_without_cancel_handler_has_no_cancel_capability() -> None: # pragma: no cover """Server without cancel_task handler has no tasks.cancel capability.""" server: Server = Server("test") - - # Register only get_task (not cancel_task) - @server.experimental.get_task() - async def handle_get(req: GetTaskRequest) -> GetTaskResult: - raise NotImplementedError + server.experimental.enable_tasks(on_get_task=_noop_get_task) caps = _get_capabilities(server) assert caps.tasks is not None @@ -119,18 +122,11 @@ async def handle_get(req: GetTaskRequest) -> GetTaskResult: def test_server_with_all_task_handlers_has_full_capability() -> None: """Server with all task handlers declares complete tasks capability.""" server: Server = Server("test") - - @server.experimental.list_tasks() - async def handle_list(req: ListTasksRequest) -> ListTasksResult: - raise NotImplementedError - - @server.experimental.cancel_task() - async def handle_cancel(req: CancelTaskRequest) -> CancelTaskResult: - raise NotImplementedError - - @server.experimental.get_task() - async def handle_get(req: GetTaskRequest) -> GetTaskResult: - raise NotImplementedError + server.experimental.enable_tasks( + on_list_tasks=_noop_list_tasks, + on_cancel_task=_noop_cancel_task, + on_get_task=_noop_get_task, + ) caps = _get_capabilities(server) assert caps.tasks is not None @@ -141,8 +137,7 @@ async def handle_get(req: GetTaskRequest) -> GetTaskResult: class TestClientCapabilities: - """ - Clients declare: + """Clients declare: - tasks.list — supports listing operations - tasks.cancel — supports cancellation - tasks.requests.sampling.createMessage — task-augmented sampling @@ -155,8 +150,7 @@ def test_client_declares_tasks_capability(self) -> None: class TestToolLevelNegotiation: - """ - Tools in tools/list responses include execution.taskSupport with values: + """Tools in tools/list responses include execution.taskSupport with values: - Not present or "forbidden": No task augmentation allowed - "optional": Task augmentation allowed at requestor discretion - "required": Task augmentation is mandatory @@ -188,8 +182,7 @@ def test_tool_execution_task_required_accepts_task_augmented_call(self) -> None: class TestCapabilityNegotiation: - """ - Requestors SHOULD only augment requests with a task if the corresponding + """Requestors SHOULD only augment requests with a task if the corresponding capability has been declared by the receiver. Receivers that do not declare the task capability for a request type @@ -198,23 +191,20 @@ class TestCapabilityNegotiation: """ def test_receiver_without_capability_ignores_task_metadata(self) -> None: - """ - Receiver without task capability MUST process request normally, + """Receiver without task capability MUST process request normally, ignoring task-augmentation metadata. """ pytest.skip("TODO") def test_receiver_with_capability_may_require_task_augmentation(self) -> None: - """ - Receivers that declare task capability MAY return error (-32600) + """Receivers that declare task capability MAY return error (-32600) for non-task-augmented requests, requiring task augmentation. """ pytest.skip("TODO") class TestTaskStatusLifecycle: - """ - Tasks begin in working status and follow valid transitions: + """Tasks begin in working status and follow valid transitions: working → input_required → working → terminal working → terminal (directly) input_required → terminal (directly) @@ -271,8 +261,7 @@ def test_cancelled_is_terminal(self) -> None: class TestInputRequiredStatus: - """ - When a receiver needs information to proceed, it moves the task to input_required. + """When a receiver needs information to proceed, it moves the task to input_required. The requestor should call tasks/result to retrieve input requests. The task must include io.modelcontextprotocol/related-task metadata in associated requests. """ @@ -282,16 +271,14 @@ def test_input_required_status_retrievable_via_tasks_get(self) -> None: pytest.skip("TODO") def test_input_required_related_task_metadata_in_requests(self) -> None: - """ - Task MUST include io.modelcontextprotocol/related-task metadata + """Task MUST include io.modelcontextprotocol/related-task metadata in associated requests. """ pytest.skip("TODO") class TestCreatingTask: - """ - Request structure: + """Request structure: {"method": "tools/call", "params": {"name": "...", "arguments": {...}, "task": {"ttl": 60000}}} Response (CreateTaskResult): @@ -337,8 +324,7 @@ def test_receiver_may_override_requested_ttl(self) -> None: pytest.skip("TODO") def test_model_immediate_response_in_meta(self) -> None: - """ - Receiver MAY include io.modelcontextprotocol/model-immediate-response + """Receiver MAY include io.modelcontextprotocol/model-immediate-response in _meta to provide immediate response while task executes. """ # Verify the constant has the correct value per spec @@ -346,10 +332,10 @@ def test_model_immediate_response_in_meta(self) -> None: # CreateTaskResult can include model-immediate-response in _meta task = Task( - taskId="test-123", + task_id="test-123", status="working", - createdAt=TEST_DATETIME, - lastUpdatedAt=TEST_DATETIME, + created_at=TEST_DATETIME, + last_updated_at=TEST_DATETIME, ttl=60000, ) immediate_msg = "Task started, processing your request..." @@ -372,8 +358,7 @@ def test_model_immediate_response_in_meta(self) -> None: class TestGettingTaskStatus: - """ - Request: {"method": "tasks/get", "params": {"taskId": "..."}} + """Request: {"method": "tasks/get", "params": {"taskId": "..."}} Response: Returns full Task object with current status and pollInterval. """ @@ -399,8 +384,7 @@ def test_tasks_get_nonexistent_task_id_returns_error(self) -> None: class TestRetrievingResults: - """ - Request: {"method": "tasks/result", "params": {"taskId": "..."}} + """Request: {"method": "tasks/result", "params": {"taskId": "..."}} Response: The actual operation result structure (e.g., CallToolResult). This call blocks until terminal status. @@ -423,8 +407,7 @@ def test_tasks_result_includes_related_task_metadata(self) -> None: pytest.skip("TODO") def test_tasks_result_returns_error_for_failed_task(self) -> None: - """ - tasks/result returns the same error the underlying request + """tasks/result returns the same error the underlying request would have produced for failed tasks. """ pytest.skip("TODO") @@ -435,8 +418,7 @@ def test_tasks_result_invalid_task_id_returns_error(self) -> None: class TestListingTasks: - """ - Request: {"method": "tasks/list", "params": {"cursor": "optional"}} + """Request: {"method": "tasks/list", "params": {"cursor": "optional"}} Response: Array of tasks with pagination support via nextCursor. """ @@ -462,8 +444,7 @@ def test_tasks_list_invalid_cursor_returns_error(self) -> None: class TestCancellingTasks: - """ - Request: {"method": "tasks/cancel", "params": {"taskId": "..."}} + """Request: {"method": "tasks/cancel", "params": {"taskId": "..."}} Response: Returns the task object with status: "cancelled". """ @@ -493,8 +474,7 @@ def test_tasks_cancel_invalid_task_id_returns_error(self) -> None: class TestStatusNotifications: - """ - Receivers MAY send: {"method": "notifications/tasks/status", "params": {...}} + """Receivers MAY send: {"method": "notifications/tasks/status", "params": {...}} These are optional; requestors MUST NOT rely on them and SHOULD continue polling. """ @@ -512,8 +492,7 @@ def test_status_notification_contains_status(self) -> None: class TestTaskManagement: - """ - - Receivers generate unique task IDs as strings + """- Receivers generate unique task IDs as strings - Tasks must begin in working status - createdAt timestamps must be ISO 8601 formatted - Receivers may override requested ttl but must return actual value @@ -535,8 +514,7 @@ def test_receiver_may_delete_tasks_after_ttl(self) -> None: pytest.skip("TODO") def test_related_task_metadata_in_task_messages(self) -> None: - """ - All task-related messages MUST include io.modelcontextprotocol/related-task + """All task-related messages MUST include io.modelcontextprotocol/related-task in _meta. """ pytest.skip("TODO") @@ -555,8 +533,7 @@ def test_tasks_cancel_does_not_require_related_task_metadata(self) -> None: class TestResultHandling: - """ - - Receivers must return CreateTaskResult immediately upon accepting task-augmented requests + """- Receivers must return CreateTaskResult immediately upon accepting task-augmented requests - tasks/result must return exactly what the underlying request would return - tasks/result blocks for non-terminal tasks; must unblock upon reaching terminal status """ @@ -575,8 +552,7 @@ def test_tasks_result_for_tool_call_returns_call_tool_result(self) -> None: class TestProgressTracking: - """ - Task-augmented requests support progress notifications using the progressToken + """Task-augmented requests support progress notifications using the progressToken mechanism, which remains valid throughout the task lifetime. """ @@ -590,8 +566,7 @@ def test_progress_notifications_sent_during_task_execution(self) -> None: class TestProtocolErrors: - """ - Protocol Errors (JSON-RPC standard codes): + """Protocol Errors (JSON-RPC standard codes): - -32600 (Invalid request): Non-task requests to endpoint requiring task augmentation - -32602 (Invalid params): Invalid/nonexistent taskId, invalid cursor, cancel terminal task - -32603 (Internal error): Server-side execution failures @@ -623,8 +598,7 @@ def test_internal_error_for_server_failure(self) -> None: class TestTaskExecutionErrors: - """ - When underlying requests fail, the task moves to failed status. + """When underlying requests fail, the task moves to failed status. - tasks/get response should include statusMessage explaining failure - tasks/result returns same error the underlying request would have produced - For tool calls, isError: true moves task to failed status @@ -648,8 +622,7 @@ def test_tool_call_is_error_true_moves_to_failed(self) -> None: class TestTaskObject: - """ - Task Object fields: + """Task Object fields: - taskId: String identifier - status: Current execution state - statusMessage: Optional human-readable description @@ -684,8 +657,7 @@ def test_task_poll_interval_is_optional(self) -> None: class TestRelatedTaskMetadata: - """ - Related Task Metadata structure: + """Related Task Metadata structure: {"_meta": {"io.modelcontextprotocol/related-task": {"taskId": "..."}}} """ @@ -699,42 +671,34 @@ def test_related_task_metadata_contains_task_id(self) -> None: class TestAccessAndIsolation: - """ - - Task IDs enable access to sensitive results + """- Task IDs enable access to sensitive results - Authorization context binding is essential where available - For non-authorized environments: strong entropy IDs, strict TTL limits """ def test_task_bound_to_authorization_context(self) -> None: - """ - Receivers receiving authorization context MUST bind tasks to that context. - """ + """Receivers receiving authorization context MUST bind tasks to that context.""" pytest.skip("TODO") def test_reject_task_operations_outside_authorization_context(self) -> None: - """ - Receivers MUST reject task operations for tasks outside + """Receivers MUST reject task operations for tasks outside requestor's authorization context. """ pytest.skip("TODO") def test_non_authorized_environments_use_secure_ids(self) -> None: - """ - For non-authorized environments, receivers SHOULD use + """For non-authorized environments, receivers SHOULD use cryptographically secure IDs. """ pytest.skip("TODO") def test_non_authorized_environments_use_shorter_ttls(self) -> None: - """ - For non-authorized environments, receivers SHOULD use shorter TTLs. - """ + """For non-authorized environments, receivers SHOULD use shorter TTLs.""" pytest.skip("TODO") class TestResourceLimits: - """ - Receivers should: + """Receivers should: - Enforce concurrent task limits per requestor - Implement maximum TTL constraints - Clean up expired tasks promptly diff --git a/tests/issues/test_100_tool_listing.py b/tests/issues/test_100_tool_listing.py index 9e3447b74..e59fb632d 100644 --- a/tests/issues/test_100_tool_listing.py +++ b/tests/issues/test_100_tool_listing.py @@ -1,12 +1,12 @@ import pytest -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer pytestmark = pytest.mark.anyio async def test_list_tools_returns_all_tools(): - mcp = FastMCP("TestTools") + mcp = MCPServer("TestTools") # Create 100 tools with unique names num_tools = 100 diff --git a/tests/issues/test_1027_win_unreachable_cleanup.py b/tests/issues/test_1027_win_unreachable_cleanup.py index 63d6dd8dc..c59c5aeca 100644 --- a/tests/issues/test_1027_win_unreachable_cleanup.py +++ b/tests/issues/test_1027_win_unreachable_cleanup.py @@ -1,5 +1,4 @@ -""" -Regression test for issue #1027: Ensure cleanup procedures run properly during shutdown +"""Regression test for issue #1027: Ensure cleanup procedures run properly during shutdown Issue #1027 reported that cleanup code after "yield" in lifespan was unreachable when processes were terminated. This has been fixed by implementing the MCP spec-compliant @@ -12,25 +11,18 @@ import tempfile import textwrap from pathlib import Path -from typing import TYPE_CHECKING import anyio import pytest from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import _create_platform_compatible_process, stdio_client - -# TODO(Marcelo): This doesn't seem to be the right path. We should fix this. -if TYPE_CHECKING: - from ..shared.test_win32_utils import escape_path_for_python -else: - from tests.shared.test_win32_utils import escape_path_for_python +from tests.shared.test_win32_utils import escape_path_for_python @pytest.mark.anyio async def test_lifespan_cleanup_executed(): - """ - Regression test ensuring MCP server cleanup code runs during shutdown. + """Regression test ensuring MCP server cleanup code runs during shutdown. This test verifies that the fix for issue #1027 works correctly by: 1. Starting an MCP server that writes a marker file on startup @@ -51,13 +43,13 @@ async def test_lifespan_cleanup_executed(): Path(startup_marker).unlink() Path(cleanup_marker).unlink() - # Create a minimal MCP server using FastMCP that tracks lifecycle + # Create a minimal MCP server using MCPServer that tracks lifecycle server_code = textwrap.dedent(f""" import asyncio import sys from pathlib import Path from contextlib import asynccontextmanager - from mcp.server.fastmcp import FastMCP + from mcp.server.mcpserver import MCPServer STARTUP_MARKER = {escape_path_for_python(startup_marker)} CLEANUP_MARKER = {escape_path_for_python(cleanup_marker)} @@ -72,7 +64,7 @@ async def lifespan(server): # This cleanup code now runs properly during shutdown Path(CLEANUP_MARKER).write_text("cleaned up") - mcp = FastMCP("test-server", lifespan=lifespan) + mcp = MCPServer("test-server", lifespan=lifespan) @mcp.tool() def echo(text: str) -> str: @@ -95,7 +87,7 @@ def echo(text: str) -> str: async with ClientSession(read, write) as session: # Initialize the session result = await session.initialize() - assert result.protocolVersion in ["2024-11-05", "2025-06-18", "2025-11-25"] + assert result.protocol_version in ["2024-11-05", "2025-06-18", "2025-11-25"] # Verify startup marker was created assert Path(startup_marker).exists(), "Server startup marker not created" @@ -110,7 +102,7 @@ def echo(text: str) -> str: # Give server a moment to complete cleanup with anyio.move_on_after(5.0): - while not Path(cleanup_marker).exists(): # pragma: no cover + while not Path(cleanup_marker).exists(): # pragma: lax no cover await anyio.sleep(0.1) # Verify cleanup marker was created - this works now that stdio_client @@ -121,17 +113,16 @@ def echo(text: str) -> str: finally: # Clean up files for path in [server_script, startup_marker, cleanup_marker]: - try: # pragma: no cover + try: # pragma: lax no cover Path(path).unlink() - except FileNotFoundError: # pragma: no cover + except FileNotFoundError: # pragma: lax no cover pass @pytest.mark.anyio @pytest.mark.filterwarnings("ignore::ResourceWarning" if sys.platform == "win32" else "default") async def test_stdin_close_triggers_cleanup(): - """ - Regression test verifying the stdin-based graceful shutdown mechanism. + """Regression test verifying the stdin-based graceful shutdown mechanism. This test ensures the core fix for issue #1027 continues to work by: 1. Manually managing a server process @@ -165,7 +156,7 @@ async def test_stdin_close_triggers_cleanup(): import sys from pathlib import Path from contextlib import asynccontextmanager - from mcp.server.fastmcp import FastMCP + from mcp.server.mcpserver import MCPServer STARTUP_MARKER = {escape_path_for_python(startup_marker)} CLEANUP_MARKER = {escape_path_for_python(cleanup_marker)} @@ -180,7 +171,7 @@ async def lifespan(server): # This cleanup code runs when stdin closes, enabling graceful shutdown Path(CLEANUP_MARKER).write_text("cleaned up") - mcp = FastMCP("test-server", lifespan=lifespan) + mcp = MCPServer("test-server", lifespan=lifespan) @mcp.tool() def echo(text: str) -> str: @@ -213,7 +204,7 @@ def echo(text: str) -> str: await anyio.sleep(0.1) # Check if process is still running - if hasattr(process, "returncode") and process.returncode is not None: # pragma: no cover + if hasattr(process, "returncode") and process.returncode is not None: # pragma: lax no cover pytest.fail(f"Server process exited with code {process.returncode}") assert Path(startup_marker).exists(), "Server startup marker not created" @@ -226,14 +217,14 @@ def echo(text: str) -> str: try: with anyio.fail_after(5.0): # Increased from 2.0 to 5.0 await process.wait() - except TimeoutError: # pragma: no cover + except TimeoutError: # pragma: lax no cover # If it doesn't exit after stdin close, terminate it process.terminate() await process.wait() # Check if cleanup ran with anyio.move_on_after(5.0): - while not Path(cleanup_marker).exists(): # pragma: no cover + while not Path(cleanup_marker).exists(): # pragma: lax no cover await anyio.sleep(0.1) # Verify the cleanup ran - stdin closure enables graceful shutdown @@ -243,7 +234,7 @@ def echo(text: str) -> str: finally: # Clean up files for path in [server_script, startup_marker, cleanup_marker]: - try: # pragma: no cover + try: # pragma: lax no cover Path(path).unlink() - except FileNotFoundError: # pragma: no cover + except FileNotFoundError: # pragma: lax no cover pass diff --git a/tests/issues/test_129_resource_templates.py b/tests/issues/test_129_resource_templates.py index 958773d12..bb4735121 100644 --- a/tests/issues/test_129_resource_templates.py +++ b/tests/issues/test_129_resource_templates.py @@ -1,15 +1,13 @@ import pytest -from mcp import types -from mcp.server.fastmcp import FastMCP +from mcp import Client +from mcp.server.mcpserver import MCPServer @pytest.mark.anyio async def test_resource_templates(): - # Create an MCP server - mcp = FastMCP("Demo") + mcp = MCPServer("Demo") - # Add a dynamic greeting resource @mcp.resource("greeting://{name}") def get_greeting(name: str) -> str: # pragma: no cover """Get a personalized greeting""" @@ -20,23 +18,16 @@ def get_user_profile(user_id: str) -> str: # pragma: no cover """Dynamic user data""" return f"Profile data for user {user_id}" - # Get the list of resource templates using the underlying server - # Note: list_resource_templates() returns a decorator that wraps the handler - # The handler returns a ServerResult with a ListResourceTemplatesResult inside - result = await mcp._mcp_server.request_handlers[types.ListResourceTemplatesRequest]( - types.ListResourceTemplatesRequest(params=None) - ) - assert isinstance(result.root, types.ListResourceTemplatesResult) - templates = result.root.resourceTemplates - - # Verify we get both templates back - assert len(templates) == 2 - - # Verify template details - greeting_template = next(t for t in templates if t.name == "get_greeting") # pragma: no cover - assert greeting_template.uriTemplate == "greeting://{name}" - assert greeting_template.description == "Get a personalized greeting" - - profile_template = next(t for t in templates if t.name == "get_user_profile") # pragma: no cover - assert profile_template.uriTemplate == "users://{user_id}/profile" - assert profile_template.description == "Dynamic user data" + async with Client(mcp) as client: + result = await client.list_resource_templates() + templates = result.resource_templates + + assert len(templates) == 2 + + greeting_template = next(t for t in templates if t.name == "get_greeting") + assert greeting_template.uri_template == "greeting://{name}" + assert greeting_template.description == "Get a personalized greeting" + + profile_template = next(t for t in templates if t.name == "get_user_profile") + assert profile_template.uri_template == "users://{user_id}/profile" + assert profile_template.description == "Dynamic user data" diff --git a/tests/issues/test_1338_icons_and_metadata.py b/tests/issues/test_1338_icons_and_metadata.py index adc37f1c6..a003f75b8 100644 --- a/tests/issues/test_1338_icons_and_metadata.py +++ b/tests/issues/test_1338_icons_and_metadata.py @@ -2,7 +2,7 @@ import pytest -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from mcp.types import Icon pytestmark = pytest.mark.anyio @@ -14,12 +14,12 @@ async def test_icons_and_website_url(): # Create test icon test_icon = Icon( src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", - mimeType="image/png", + mime_type="image/png", sizes=["1x1"], ) # Create server with website URL and icon - mcp = FastMCP("TestServer", website_url="https://example.com", icons=[test_icon]) + mcp = MCPServer("TestServer", website_url="https://example.com", icons=[test_icon]) # Create tool with icon @mcp.tool(icons=[test_icon]) @@ -51,7 +51,7 @@ def test_resource_template(city: str) -> str: # pragma: no cover assert mcp.icons is not None assert len(mcp.icons) == 1 assert mcp.icons[0].src == test_icon.src - assert mcp.icons[0].mimeType == test_icon.mimeType + assert mcp.icons[0].mime_type == test_icon.mime_type assert mcp.icons[0].sizes == test_icon.sizes # Test tool includes icon @@ -86,7 +86,7 @@ def test_resource_template(city: str) -> str: # pragma: no cover assert len(templates) == 1 template = templates[0] assert template.name == "test_resource_template" - assert template.uriTemplate == "test://weather/{city}" + assert template.uri_template == "test://weather/{city}" assert template.icons is not None assert len(template.icons) == 1 assert template.icons[0].src == test_icon.src @@ -96,11 +96,11 @@ async def test_multiple_icons(): """Test that multiple icons can be added to tools, resources, and prompts.""" # Create multiple test icons - icon1 = Icon(src="data:image/png;base64,icon1", mimeType="image/png", sizes=["16x16"]) - icon2 = Icon(src="data:image/png;base64,icon2", mimeType="image/png", sizes=["32x32"]) - icon3 = Icon(src="data:image/png;base64,icon3", mimeType="image/png", sizes=["64x64"]) + icon1 = Icon(src="data:image/png;base64,icon1", mime_type="image/png", sizes=["16x16"]) + icon2 = Icon(src="data:image/png;base64,icon2", mime_type="image/png", sizes=["32x32"]) + icon3 = Icon(src="data:image/png;base64,icon3", mime_type="image/png", sizes=["64x64"]) - mcp = FastMCP("MultiIconServer") + mcp = MCPServer("MultiIconServer") # Create tool with multiple icons @mcp.tool(icons=[icon1, icon2, icon3]) @@ -122,7 +122,7 @@ def multi_icon_tool() -> str: # pragma: no cover async def test_no_icons_or_website(): """Test that server works without icons or websiteUrl.""" - mcp = FastMCP("BasicServer") + mcp = MCPServer("BasicServer") @mcp.tool() def basic_tool() -> str: # pragma: no cover diff --git a/tests/issues/test_1363_race_condition_streamable_http.py b/tests/issues/test_1363_race_condition_streamable_http.py index 49242d6d8..db2a82d07 100644 --- a/tests/issues/test_1363_race_condition_streamable_http.py +++ b/tests/issues/test_1363_race_condition_streamable_http.py @@ -90,8 +90,7 @@ def stop(self) -> None: def check_logs_for_race_condition_errors(caplog: pytest.LogCaptureFixture, test_name: str) -> None: - """ - Check logs for ClosedResourceError and other race condition errors. + """Check logs for ClosedResourceError and other race condition errors. Args: caplog: pytest log capture fixture @@ -100,7 +99,7 @@ def check_logs_for_race_condition_errors(caplog: pytest.LogCaptureFixture, test_ # Check for specific race condition errors in logs errors_found: list[str] = [] - for record in caplog.records: # pragma: no cover + for record in caplog.records: # pragma: lax no cover message = record.getMessage() if "ClosedResourceError" in message: errors_found.append("ClosedResourceError") @@ -121,8 +120,7 @@ def check_logs_for_race_condition_errors(caplog: pytest.LogCaptureFixture, test_ @pytest.mark.anyio async def test_race_condition_invalid_accept_headers(caplog: pytest.LogCaptureFixture): - """ - Test the race condition with invalid Accept headers. + """Test the race condition with invalid Accept headers. This test reproduces the exact scenario described in issue #1363: - Send POST request with incorrect Accept headers (missing either application/json or text/event-stream) @@ -196,8 +194,7 @@ async def test_race_condition_invalid_accept_headers(caplog: pytest.LogCaptureFi @pytest.mark.anyio async def test_race_condition_invalid_content_type(caplog: pytest.LogCaptureFixture): - """ - Test the race condition with invalid Content-Type headers. + """Test the race condition with invalid Content-Type headers. This test reproduces the race condition scenario with Content-Type validation failure. """ @@ -237,8 +234,7 @@ async def test_race_condition_invalid_content_type(caplog: pytest.LogCaptureFixt @pytest.mark.anyio async def test_race_condition_message_router_async_for(caplog: pytest.LogCaptureFixture): - """ - Uses json_response=True to trigger the `if self.is_json_response_enabled` branch, + """Uses json_response=True to trigger the `if self.is_json_response_enabled` branch, which reproduces the ClosedResourceError when message_router is suspended in async for loop while transport cleanup closes streams concurrently. """ diff --git a/tests/issues/test_141_resource_templates.py b/tests/issues/test_141_resource_templates.py index 0a0484d89..f5c5081c3 100644 --- a/tests/issues/test_141_resource_templates.py +++ b/tests/issues/test_141_resource_templates.py @@ -1,10 +1,8 @@ import pytest -from pydantic import AnyUrl -from mcp.server.fastmcp import FastMCP -from mcp.shared.memory import ( - create_connected_server_and_client_session as client_session, -) +from mcp import Client +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.exceptions import ResourceError from mcp.types import ( ListResourceTemplatesResult, TextResourceContents, @@ -14,7 +12,7 @@ @pytest.mark.anyio async def test_resource_template_edge_cases(): """Test server-side resource template validation""" - mcp = FastMCP("Demo") + mcp = MCPServer("Demo") # Test case 1: Template with multiple parameters @mcp.resource("resource://users/{user_id}/posts/{post_id}") @@ -57,17 +55,17 @@ def get_user_profile_missing(user_id: str) -> str: # pragma: no cover assert result_list[0].mime_type == "text/plain" # Verify invalid parameters raise error - with pytest.raises(ValueError, match="Unknown resource"): + with pytest.raises(ResourceError, match="Unknown resource"): await mcp.read_resource("resource://users/123/posts") # Missing post_id - with pytest.raises(ValueError, match="Unknown resource"): + with pytest.raises(ResourceError, match="Unknown resource"): await mcp.read_resource("resource://users/123/posts/456/extra") # Extra path component @pytest.mark.anyio async def test_resource_template_client_interaction(): """Test client-side resource template interaction""" - mcp = FastMCP("Demo") + mcp = MCPServer("Demo") # Register some templated resources @mcp.resource("resource://users/{user_id}/posts/{post_id}") @@ -78,37 +76,34 @@ def get_user_post(user_id: str, post_id: str) -> str: def get_user_profile(user_id: str) -> str: return f"Profile for user {user_id}" - async with client_session(mcp._mcp_server) as session: - # Initialize the session - await session.initialize() - + async with Client(mcp) as session: # List available resources resources = await session.list_resource_templates() assert isinstance(resources, ListResourceTemplatesResult) - assert len(resources.resourceTemplates) == 2 + assert len(resources.resource_templates) == 2 # Verify resource templates are listed correctly - templates = [r.uriTemplate for r in resources.resourceTemplates] + templates = [r.uri_template for r in resources.resource_templates] assert "resource://users/{user_id}/posts/{post_id}" in templates assert "resource://users/{user_id}/profile" in templates # Read a resource with valid parameters - result = await session.read_resource(AnyUrl("resource://users/123/posts/456")) + result = await session.read_resource("resource://users/123/posts/456") contents = result.contents[0] assert isinstance(contents, TextResourceContents) assert contents.text == "Post 456 by user 123" - assert contents.mimeType == "text/plain" + assert contents.mime_type == "text/plain" # Read another resource with valid parameters - result = await session.read_resource(AnyUrl("resource://users/789/profile")) + result = await session.read_resource("resource://users/789/profile") contents = result.contents[0] assert isinstance(contents, TextResourceContents) assert contents.text == "Profile for user 789" - assert contents.mimeType == "text/plain" + assert contents.mime_type == "text/plain" # Verify invalid resource URIs raise appropriate errors with pytest.raises(Exception): # Specific exception type may vary - await session.read_resource(AnyUrl("resource://users/123/posts")) # Missing post_id + await session.read_resource("resource://users/123/posts") # Missing post_id with pytest.raises(Exception): # Specific exception type may vary - await session.read_resource(AnyUrl("resource://users/123/invalid")) # Invalid template + await session.read_resource("resource://users/123/invalid") # Invalid template diff --git a/tests/issues/test_152_resource_mime_type.py b/tests/issues/test_152_resource_mime_type.py index 2a8cd6202..851e89979 100644 --- a/tests/issues/test_152_resource_mime_type.py +++ b/tests/issues/test_152_resource_mime_type.py @@ -1,22 +1,25 @@ import base64 import pytest -from pydantic import AnyUrl - -from mcp import types -from mcp.server.fastmcp import FastMCP -from mcp.server.lowlevel import Server -from mcp.server.lowlevel.helper_types import ReadResourceContents -from mcp.shared.memory import ( - create_connected_server_and_client_session as client_session, + +from mcp import Client, types +from mcp.server import Server, ServerRequestContext +from mcp.server.mcpserver import MCPServer +from mcp.types import ( + BlobResourceContents, + ListResourcesResult, + PaginatedRequestParams, + ReadResourceRequestParams, + ReadResourceResult, + TextResourceContents, ) pytestmark = pytest.mark.anyio -async def test_fastmcp_resource_mime_type(): +async def test_mcpserver_resource_mime_type(): """Test that mime_type parameter is respected for resources.""" - mcp = FastMCP("test") + mcp = MCPServer("test") # Create a small test image as bytes image_bytes = b"fake_image_data" @@ -33,7 +36,7 @@ def get_image_as_bytes() -> bytes: return image_bytes # Test that resources are listed with correct mime type - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: # List resources and verify mime types resources = await client.list_resources() assert resources.resources is not None @@ -45,24 +48,23 @@ def get_image_as_bytes() -> bytes: bytes_resource = mapping["test://image_bytes"] # Verify mime types - assert string_resource.mimeType == "image/png", "String resource mime type not respected" - assert bytes_resource.mimeType == "image/png", "Bytes resource mime type not respected" + assert string_resource.mime_type == "image/png", "String resource mime type not respected" + assert bytes_resource.mime_type == "image/png", "Bytes resource mime type not respected" # Also verify the content can be read correctly - string_result = await client.read_resource(AnyUrl("test://image")) + string_result = await client.read_resource("test://image") assert len(string_result.contents) == 1 assert getattr(string_result.contents[0], "text") == base64_string, "Base64 string mismatch" - assert string_result.contents[0].mimeType == "image/png", "String content mime type not preserved" + assert string_result.contents[0].mime_type == "image/png", "String content mime type not preserved" - bytes_result = await client.read_resource(AnyUrl("test://image_bytes")) + bytes_result = await client.read_resource("test://image_bytes") assert len(bytes_result.contents) == 1 assert base64.b64decode(getattr(bytes_result.contents[0], "blob")) == image_bytes, "Bytes mismatch" - assert bytes_result.contents[0].mimeType == "image/png", "Bytes content mime type not preserved" + assert bytes_result.contents[0].mime_type == "image/png", "Bytes content mime type not preserved" async def test_lowlevel_resource_mime_type(): """Test that mime_type parameter is respected for resources.""" - server = Server("test") # Create a small test image as bytes image_bytes = b"fake_image_data" @@ -70,28 +72,35 @@ async def test_lowlevel_resource_mime_type(): # Create test resources with specific mime types test_resources = [ - types.Resource(uri=AnyUrl("test://image"), name="test image", mimeType="image/png"), + types.Resource(uri="test://image", name="test image", mime_type="image/png"), types.Resource( - uri=AnyUrl("test://image_bytes"), + uri="test://image_bytes", name="test image bytes", - mimeType="image/png", + mime_type="image/png", ), ] - @server.list_resources() - async def handle_list_resources(): - return test_resources + async def handle_list_resources( + ctx: ServerRequestContext, params: PaginatedRequestParams | None + ) -> ListResourcesResult: + return ListResourcesResult(resources=test_resources) + + resource_contents: dict[str, list[TextResourceContents | BlobResourceContents]] = { + "test://image": [TextResourceContents(uri="test://image", text=base64_string, mime_type="image/png")], + "test://image_bytes": [ + BlobResourceContents( + uri="test://image_bytes", blob=base64.b64encode(image_bytes).decode("utf-8"), mime_type="image/png" + ) + ], + } + + async def handle_read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult: + return ReadResourceResult(contents=resource_contents[str(params.uri)]) - @server.read_resource() - async def handle_read_resource(uri: AnyUrl): - if str(uri) == "test://image": - return [ReadResourceContents(content=base64_string, mime_type="image/png")] - elif str(uri) == "test://image_bytes": - return [ReadResourceContents(content=bytes(image_bytes), mime_type="image/png")] - raise Exception(f"Resource not found: {uri}") # pragma: no cover + server = Server("test", on_list_resources=handle_list_resources, on_read_resource=handle_read_resource) # Test that resources are listed with correct mime type - async with client_session(server) as client: + async with Client(server) as client: # List resources and verify mime types resources = await client.list_resources() assert resources.resources is not None @@ -103,16 +112,16 @@ async def handle_read_resource(uri: AnyUrl): bytes_resource = mapping["test://image_bytes"] # Verify mime types - assert string_resource.mimeType == "image/png", "String resource mime type not respected" - assert bytes_resource.mimeType == "image/png", "Bytes resource mime type not respected" + assert string_resource.mime_type == "image/png", "String resource mime type not respected" + assert bytes_resource.mime_type == "image/png", "Bytes resource mime type not respected" # Also verify the content can be read correctly - string_result = await client.read_resource(AnyUrl("test://image")) + string_result = await client.read_resource("test://image") assert len(string_result.contents) == 1 assert getattr(string_result.contents[0], "text") == base64_string, "Base64 string mismatch" - assert string_result.contents[0].mimeType == "image/png", "String content mime type not preserved" + assert string_result.contents[0].mime_type == "image/png", "String content mime type not preserved" - bytes_result = await client.read_resource(AnyUrl("test://image_bytes")) + bytes_result = await client.read_resource("test://image_bytes") assert len(bytes_result.contents) == 1 assert base64.b64decode(getattr(bytes_result.contents[0], "blob")) == image_bytes, "Bytes mismatch" - assert bytes_result.contents[0].mimeType == "image/png", "Bytes content mime type not preserved" + assert bytes_result.contents[0].mime_type == "image/png", "Bytes content mime type not preserved" diff --git a/tests/issues/test_1574_resource_uri_validation.py b/tests/issues/test_1574_resource_uri_validation.py new file mode 100644 index 000000000..c67708128 --- /dev/null +++ b/tests/issues/test_1574_resource_uri_validation.py @@ -0,0 +1,140 @@ +"""Tests for issue #1574: Python SDK incorrectly validates Resource URIs. + +The Python SDK previously used Pydantic's AnyUrl for URI fields, which rejected +relative paths like 'users/me' that are valid according to the MCP spec and +accepted by the TypeScript SDK. + +The fix changed URI fields to plain strings to match the spec, which defines +uri fields as strings with no JSON Schema format validation. + +These tests verify the fix works end-to-end through the JSON-RPC protocol. +""" + +import pytest + +from mcp import Client, types +from mcp.server import Server, ServerRequestContext +from mcp.types import ( + ListResourcesResult, + PaginatedRequestParams, + ReadResourceRequestParams, + ReadResourceResult, + TextResourceContents, +) + +pytestmark = pytest.mark.anyio + + +async def test_relative_uri_roundtrip(): + """Relative URIs survive the full server-client JSON-RPC roundtrip. + + This is the critical regression test - if someone reintroduces AnyUrl, + the server would fail to serialize resources with relative URIs, + or the URI would be transformed during the roundtrip. + """ + + async def handle_list_resources( + ctx: ServerRequestContext, params: PaginatedRequestParams | None + ) -> ListResourcesResult: + return ListResourcesResult( + resources=[ + types.Resource(name="user", uri="users/me"), + types.Resource(name="config", uri="./config"), + types.Resource(name="parent", uri="../parent/resource"), + ] + ) + + async def handle_read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult: + return ReadResourceResult( + contents=[TextResourceContents(uri=str(params.uri), text=f"data for {params.uri}", mime_type="text/plain")] + ) + + server = Server("test", on_list_resources=handle_list_resources, on_read_resource=handle_read_resource) + + async with Client(server) as client: + # List should return the exact URIs we specified + resources = await client.list_resources() + uri_map = {r.uri: r for r in resources.resources} + + assert "users/me" in uri_map, f"Expected 'users/me' in {list(uri_map.keys())}" + assert "./config" in uri_map, f"Expected './config' in {list(uri_map.keys())}" + assert "../parent/resource" in uri_map, f"Expected '../parent/resource' in {list(uri_map.keys())}" + + # Read should work with each relative URI and preserve it in the response + for uri_str in ["users/me", "./config", "../parent/resource"]: + result = await client.read_resource(uri_str) + assert len(result.contents) == 1 + assert result.contents[0].uri == uri_str + + +async def test_custom_scheme_uri_roundtrip(): + """Custom scheme URIs work through the protocol. + + Some MCP servers use custom schemes like "custom://resource". + These should work end-to-end. + """ + + async def handle_list_resources( + ctx: ServerRequestContext, params: PaginatedRequestParams | None + ) -> ListResourcesResult: + return ListResourcesResult( + resources=[ + types.Resource(name="custom", uri="custom://my-resource"), + types.Resource(name="file", uri="file:///path/to/file"), + ] + ) + + async def handle_read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult: + return ReadResourceResult( + contents=[TextResourceContents(uri=str(params.uri), text="data", mime_type="text/plain")] + ) + + server = Server("test", on_list_resources=handle_list_resources, on_read_resource=handle_read_resource) + + async with Client(server) as client: + resources = await client.list_resources() + uri_map = {r.uri: r for r in resources.resources} + + assert "custom://my-resource" in uri_map + assert "file:///path/to/file" in uri_map + + # Read with custom scheme + result = await client.read_resource("custom://my-resource") + assert len(result.contents) == 1 + + +def test_uri_json_roundtrip_preserves_value(): + """URI is preserved exactly through JSON serialization. + + This catches any Pydantic validation or normalization that would + alter the URI during the JSON-RPC message flow. + """ + test_uris = [ + "users/me", + "custom://resource", + "./relative", + "../parent", + "file:///absolute/path", + "https://example.com/path", + ] + + for uri_str in test_uris: + resource = types.Resource(name="test", uri=uri_str) + json_data = resource.model_dump(mode="json") + restored = types.Resource.model_validate(json_data) + assert restored.uri == uri_str, f"URI mutated: {uri_str} -> {restored.uri}" + + +def test_resource_contents_uri_json_roundtrip(): + """TextResourceContents URI is preserved through JSON serialization.""" + test_uris = ["users/me", "./relative", "custom://resource"] + + for uri_str in test_uris: + contents = types.TextResourceContents( + uri=uri_str, + text="data", + mime_type="text/plain", + ) + json_data = contents.model_dump(mode="json") + restored = types.TextResourceContents.model_validate(json_data) + assert restored.uri == uri_str, f"URI mutated: {uri_str} -> {restored.uri}" diff --git a/tests/issues/test_1754_mime_type_parameters.py b/tests/issues/test_1754_mime_type_parameters.py index cd8239ad2..7903fd560 100644 --- a/tests/issues/test_1754_mime_type_parameters.py +++ b/tests/issues/test_1754_mime_type_parameters.py @@ -5,19 +5,16 @@ """ import pytest -from pydantic import AnyUrl -from mcp.server.fastmcp import FastMCP -from mcp.shared.memory import ( - create_connected_server_and_client_session as client_session, -) +from mcp import Client +from mcp.server.mcpserver import MCPServer pytestmark = pytest.mark.anyio async def test_mime_type_with_parameters(): """Test that MIME types with parameters are accepted (RFC 2045).""" - mcp = FastMCP("test") + mcp = MCPServer("test") # This should NOT raise a validation error @mcp.resource("ui://widget", mime_type="text/html;profile=mcp-app") @@ -26,12 +23,12 @@ def widget() -> str: resources = await mcp.list_resources() assert len(resources) == 1 - assert resources[0].mimeType == "text/html;profile=mcp-app" + assert resources[0].mime_type == "text/html;profile=mcp-app" async def test_mime_type_with_parameters_and_space(): """Test MIME type with space after semicolon.""" - mcp = FastMCP("test") + mcp = MCPServer("test") @mcp.resource("data://json", mime_type="application/json; charset=utf-8") def data() -> str: @@ -39,12 +36,12 @@ def data() -> str: resources = await mcp.list_resources() assert len(resources) == 1 - assert resources[0].mimeType == "application/json; charset=utf-8" + assert resources[0].mime_type == "application/json; charset=utf-8" async def test_mime_type_with_multiple_parameters(): """Test MIME type with multiple parameters.""" - mcp = FastMCP("test") + mcp = MCPServer("test") @mcp.resource("data://multi", mime_type="text/plain; charset=utf-8; format=fixed") def data() -> str: @@ -52,19 +49,19 @@ def data() -> str: resources = await mcp.list_resources() assert len(resources) == 1 - assert resources[0].mimeType == "text/plain; charset=utf-8; format=fixed" + assert resources[0].mime_type == "text/plain; charset=utf-8; format=fixed" async def test_mime_type_preserved_in_read_resource(): """Test that MIME type with parameters is preserved when reading resource.""" - mcp = FastMCP("test") + mcp = MCPServer("test") @mcp.resource("ui://my-widget", mime_type="text/html;profile=mcp-app") def my_widget() -> str: return "<html><body>Hello MCP-UI</body></html>" - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: # Read the resource - result = await client.read_resource(AnyUrl("ui://my-widget")) + result = await client.read_resource("ui://my-widget") assert len(result.contents) == 1 - assert result.contents[0].mimeType == "text/html;profile=mcp-app" + assert result.contents[0].mime_type == "text/html;profile=mcp-app" diff --git a/tests/issues/test_176_progress_token.py b/tests/issues/test_176_progress_token.py index eb5f19d64..5d5f8b8fc 100644 --- a/tests/issues/test_176_progress_token.py +++ b/tests/issues/test_176_progress_token.py @@ -2,8 +2,9 @@ import pytest -from mcp.server.fastmcp import Context -from mcp.shared.context import RequestContext +from mcp.server.context import ServerRequestContext +from mcp.server.experimental.request_context import Experimental +from mcp.server.mcpserver import Context pytestmark = pytest.mark.anyio @@ -16,18 +17,16 @@ async def test_progress_token_zero_first_call(): mock_session.send_progress_notification = AsyncMock() # Create request context with progress token 0 - mock_meta = MagicMock() - mock_meta.progressToken = 0 # This is the key test case - token is 0 - - request_context = RequestContext( + request_context = ServerRequestContext( request_id="test-request", session=mock_session, - meta=mock_meta, + meta={"progress_token": 0}, lifespan_context=None, + experimental=Experimental(), ) # Create context with our mocks - ctx = Context(request_context=request_context, fastmcp=MagicMock()) + ctx = Context(request_context=request_context, mcp_server=MagicMock()) # Test progress reporting await ctx.report_progress(0, 10) # First call with 0 @@ -36,6 +35,12 @@ async def test_progress_token_zero_first_call(): # Verify progress notifications assert mock_session.send_progress_notification.call_count == 3, "All progress notifications should be sent" - mock_session.send_progress_notification.assert_any_call(progress_token=0, progress=0.0, total=10.0, message=None) - mock_session.send_progress_notification.assert_any_call(progress_token=0, progress=5.0, total=10.0, message=None) - mock_session.send_progress_notification.assert_any_call(progress_token=0, progress=10.0, total=10.0, message=None) + mock_session.send_progress_notification.assert_any_call( + progress_token=0, progress=0.0, total=10.0, message=None, related_request_id="test-request" + ) + mock_session.send_progress_notification.assert_any_call( + progress_token=0, progress=5.0, total=10.0, message=None, related_request_id="test-request" + ) + mock_session.send_progress_notification.assert_any_call( + progress_token=0, progress=10.0, total=10.0, message=None, related_request_id="test-request" + ) diff --git a/tests/issues/test_188_concurrency.py b/tests/issues/test_188_concurrency.py index 831736510..0e11f6148 100644 --- a/tests/issues/test_188_concurrency.py +++ b/tests/issues/test_188_concurrency.py @@ -1,14 +1,13 @@ import anyio import pytest -from pydantic import AnyUrl -from mcp.server.fastmcp import FastMCP -from mcp.shared.memory import create_connected_server_and_client_session as create_session +from mcp import Client +from mcp.server.mcpserver import MCPServer @pytest.mark.anyio async def test_messages_are_executed_concurrently_tools(): - server = FastMCP("test") + server = MCPServer("test") event = anyio.Event() tool_started = anyio.Event() call_order: list[str] = [] @@ -30,7 +29,7 @@ async def trigger(): call_order.append("trigger_end") return "slow" - async with create_session(server._mcp_server) as client_session: + async with Client(server) as client_session: # First tool will wait on event, second will set it async with anyio.create_task_group() as tg: # Start the tool first (it will wait on event) @@ -49,7 +48,7 @@ async def trigger(): @pytest.mark.anyio async def test_messages_are_executed_concurrently_tools_and_resources(): - server = FastMCP("test") + server = MCPServer("test") event = anyio.Event() tool_started = anyio.Event() call_order: list[str] = [] @@ -70,13 +69,13 @@ async def slow_resource(): call_order.append("resource_end") return "slow" - async with create_session(server._mcp_server) as client_session: + async with Client(server) as client_session: # First tool will wait on event, second will set it async with anyio.create_task_group() as tg: # Start the tool first (it will wait on event) tg.start_soon(client_session.call_tool, "sleep") # Then the resource (it will set the event) - tg.start_soon(client_session.read_resource, AnyUrl("slow://slow_resource")) + tg.start_soon(client_session.read_resource, "slow://slow_resource") # Verify that both ran concurrently assert call_order == [ diff --git a/tests/issues/test_192_request_id.py b/tests/issues/test_192_request_id.py index 3762b092b..de96dbe23 100644 --- a/tests/issues/test_192_request_id.py +++ b/tests/issues/test_192_request_id.py @@ -59,14 +59,14 @@ async def run_server(): id="init-1", method="initialize", params=InitializeRequestParams( - protocolVersion=LATEST_PROTOCOL_VERSION, + protocol_version=LATEST_PROTOCOL_VERSION, capabilities=ClientCapabilities(), - clientInfo=Implementation(name="test-client", version="1.0.0"), + client_info=Implementation(name="test-client", version="1.0.0"), ).model_dump(by_alias=True, exclude_none=True), jsonrpc="2.0", ) - await client_writer.send(SessionMessage(JSONRPCMessage(root=init_req))) + await client_writer.send(SessionMessage(init_req)) response = await server_reader.receive() # Get init response but don't need to check it # Send initialized notification @@ -75,12 +75,12 @@ async def run_server(): params=NotificationParams().model_dump(by_alias=True, exclude_none=True), jsonrpc="2.0", ) - await client_writer.send(SessionMessage(JSONRPCMessage(root=initialized_notification))) + await client_writer.send(SessionMessage(initialized_notification)) # Send ping request with custom ID ping_request = JSONRPCRequest(id=custom_request_id, method="ping", params={}, jsonrpc="2.0") - await client_writer.send(SessionMessage(JSONRPCMessage(root=ping_request))) + await client_writer.send(SessionMessage(ping_request)) # Read response response = await server_reader.receive() @@ -88,8 +88,8 @@ async def run_server(): # Verify response ID matches request ID assert isinstance(response, SessionMessage) assert isinstance(response.message, JSONRPCMessage) - assert isinstance(response.message.root, JSONRPCResponse) - assert response.message.root.id == custom_request_id, "Response ID should match request ID" + assert isinstance(response.message, JSONRPCResponse) + assert response.message.id == custom_request_id, "Response ID should match request ID" # Cancel server task tg.cancel_scope.cancel() diff --git a/tests/issues/test_342_base64_encoding.py b/tests/issues/test_342_base64_encoding.py index da5695997..2bccedf8d 100644 --- a/tests/issues/test_342_base64_encoding.py +++ b/tests/issues/test_342_base64_encoding.py @@ -1,84 +1,52 @@ """Test for base64 encoding issue in MCP server. -This test demonstrates the issue in server.py where the server uses -urlsafe_b64encode but the BlobResourceContents validator expects standard -base64 encoding. - -The test should FAIL before fixing server.py to use b64encode instead of -urlsafe_b64encode. -After the fix, the test should PASS. +This test verifies that binary resource data is encoded with standard base64 +(not urlsafe_b64encode), so BlobResourceContents validation succeeds. """ import base64 -from typing import cast import pytest -from pydantic import AnyUrl -from mcp.server.lowlevel.helper_types import ReadResourceContents -from mcp.server.lowlevel.server import Server -from mcp.types import ( - BlobResourceContents, - ReadResourceRequest, - ReadResourceRequestParams, - ReadResourceResult, - ServerResult, -) +from mcp import Client +from mcp.server.mcpserver import MCPServer +from mcp.types import BlobResourceContents +pytestmark = pytest.mark.anyio -@pytest.mark.anyio -async def test_server_base64_encoding_issue(): - """Tests that server response can be validated by BlobResourceContents. - This test will: - 1. Set up a server that returns binary data - 2. Extract the base64-encoded blob from the server's response - 3. Verify the encoded data can be properly validated by BlobResourceContents +async def test_server_base64_encoding(): + """Tests that binary resource data round-trips correctly through base64 encoding. - BEFORE FIX: The test will fail because server uses urlsafe_b64encode - AFTER FIX: The test will pass because server uses standard b64encode + The test uses binary data that produces different results with urlsafe vs standard + base64, ensuring the server uses standard encoding. """ - server = Server("test") + mcp = MCPServer("test") # Create binary data that will definitely result in + and / characters # when encoded with standard base64 binary_data = bytes(list(range(255)) * 4) - # Register a resource handler that returns our test data - @server.read_resource() - async def read_resource(uri: AnyUrl) -> list[ReadResourceContents]: - return [ReadResourceContents(content=binary_data, mime_type="application/octet-stream")] - - # Get the handler directly from the server - handler = server.request_handlers[ReadResourceRequest] - - # Create a request - request = ReadResourceRequest( - params=ReadResourceRequestParams(uri=AnyUrl("test://resource")), - ) - - # Call the handler to get the response - result: ServerResult = await handler(request) - - # After (fixed code): - read_result: ReadResourceResult = cast(ReadResourceResult, result.root) - blob_content = read_result.contents[0] - - # First verify our test data actually produces different encodings + # Sanity check: our test data produces different encodings urlsafe_b64 = base64.urlsafe_b64encode(binary_data).decode() standard_b64 = base64.b64encode(binary_data).decode() - assert urlsafe_b64 != standard_b64, "Test data doesn't demonstrate" - " encoding difference" + assert urlsafe_b64 != standard_b64, "Test data doesn't demonstrate encoding difference" + + @mcp.resource("test://binary", mime_type="application/octet-stream") + def get_binary() -> bytes: + """Return binary test data.""" + return binary_data + + async with Client(mcp) as client: + result = await client.read_resource("test://binary") + assert len(result.contents) == 1 - # Now validate the server's output with BlobResourceContents.model_validate - # Before the fix: This should fail with "Invalid base64" because server - # uses urlsafe_b64encode - # After the fix: This should pass because server will use standard b64encode - model_dict = blob_content.model_dump() + blob_content = result.contents[0] + assert isinstance(blob_content, BlobResourceContents) - # Direct validation - this will fail before fix, pass after fix - blob_model = BlobResourceContents.model_validate(model_dict) + # Verify standard base64 was used (not urlsafe) + assert blob_content.blob == standard_b64 - # Verify we can decode the data back correctly - decoded = base64.b64decode(blob_model.blob) - assert decoded == binary_data + # Verify we can decode the data back correctly + decoded = base64.b64decode(blob_content.blob) + assert decoded == binary_data diff --git a/tests/issues/test_355_type_error.py b/tests/issues/test_355_type_error.py index 63ed80384..905cf7eee 100644 --- a/tests/issues/test_355_type_error.py +++ b/tests/issues/test_355_type_error.py @@ -2,8 +2,7 @@ from contextlib import asynccontextmanager from dataclasses import dataclass -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession +from mcp.server.mcpserver import Context, MCPServer class Database: # Replace with your actual DB type @@ -19,7 +18,7 @@ def query(self): # pragma: no cover # Create a named server -mcp = FastMCP("My App") +mcp = MCPServer("My App") @dataclass @@ -28,7 +27,7 @@ class AppContext: @asynccontextmanager -async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: # pragma: no cover +async def app_lifespan(server: MCPServer) -> AsyncIterator[AppContext]: # pragma: no cover """Manage application lifecycle with type-safe context""" # Initialize on startup db = await Database.connect() @@ -40,12 +39,12 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: # pragma: # Pass lifespan to server -mcp = FastMCP("My App", lifespan=app_lifespan) +mcp = MCPServer("My App", lifespan=app_lifespan) # Access type-safe lifespan context in tools @mcp.tool() -def query_db(ctx: Context[ServerSession, AppContext]) -> str: # pragma: no cover +def query_db(ctx: Context[AppContext]) -> str: # pragma: no cover """Tool that uses initialized resources""" db = ctx.request_context.lifespan_context.db return db.query() diff --git a/tests/issues/test_552_windows_hang.py b/tests/issues/test_552_windows_hang.py index 972659c2b..1adb5d80c 100644 --- a/tests/issues/test_552_windows_hang.py +++ b/tests/issues/test_552_windows_hang.py @@ -13,8 +13,7 @@ @pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific test") # pragma: no cover @pytest.mark.anyio async def test_windows_stdio_client_with_session(): - """ - Test the exact scenario from issue #552: Using ClientSession with stdio_client. + """Test the exact scenario from issue #552: Using ClientSession with stdio_client. This reproduces the original bug report where stdio_client hangs on Windows 11 when used with ClientSession. diff --git a/tests/issues/test_88_random_error.py b/tests/issues/test_88_random_error.py index 42f5ce407..6b593d2a5 100644 --- a/tests/issues/test_88_random_error.py +++ b/tests/issues/test_88_random_error.py @@ -1,9 +1,6 @@ """Test to reproduce issue #88: Random error thrown on response.""" -from collections.abc import Sequence -from datetime import timedelta from pathlib import Path -from typing import Any import anyio import pytest @@ -12,10 +9,10 @@ from mcp import types from mcp.client.session import ClientSession -from mcp.server.lowlevel import Server -from mcp.shared.exceptions import McpError +from mcp.server import Server, ServerRequestContext +from mcp.shared.exceptions import MCPError from mcp.shared.message import SessionMessage -from mcp.types import ContentBlock, TextContent +from mcp.types import CallToolRequestParams, CallToolResult, ListToolsResult, PaginatedRequestParams, TextContent @pytest.mark.anyio @@ -33,36 +30,38 @@ async def test_notification_validation_error(tmp_path: Path): - Slow operations use minimal timeout (10ms) for quick test execution """ - server = Server(name="test") request_count = 0 slow_request_lock = anyio.Event() - @server.list_tools() - async def list_tools() -> list[types.Tool]: - return [ - types.Tool( - name="slow", - description="A slow tool", - inputSchema={"type": "object"}, - ), - types.Tool( - name="fast", - description="A fast tool", - inputSchema={"type": "object"}, - ), - ] - - @server.call_tool() - async def slow_tool(name: str, arguments: dict[str, Any]) -> Sequence[ContentBlock]: + async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult( + tools=[ + types.Tool( + name="slow", + description="A slow tool", + input_schema={"type": "object"}, + ), + types.Tool( + name="fast", + description="A fast tool", + input_schema={"type": "object"}, + ), + ] + ) + + async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: nonlocal request_count request_count += 1 + assert params.name in ("slow", "fast"), f"Unknown tool: {params.name}" - if name == "slow": + if params.name == "slow": await slow_request_lock.wait() # it should timeout here - return [TextContent(type="text", text=f"slow {request_count}")] - elif name == "fast": - return [TextContent(type="text", text=f"fast {request_count}")] - return [TextContent(type="text", text=f"unknown {request_count}")] # pragma: no cover + text = f"slow {request_count}" + else: + text = f"fast {request_count}" + return CallToolResult(content=[TextContent(type="text", text=text)]) + + server = Server(name="test", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool) async def server_handler( read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], @@ -93,11 +92,9 @@ async def client( assert not slow_request_lock.is_set() # Second call should timeout (slow operation with minimal timeout) - # Use 10ms timeout to trigger quickly without waiting - with pytest.raises(McpError) as exc_info: - await session.call_tool( - "slow", read_timeout_seconds=timedelta(microseconds=1) - ) # artificial timeout that always fails + # Use very small timeout to trigger quickly without waiting + with pytest.raises(MCPError) as exc_info: + await session.call_tool("slow", read_timeout_seconds=0.000001) # artificial timeout that always fails assert "Timed out while waiting" in str(exc_info.value) # release the slow request not to have hanging process @@ -107,7 +104,7 @@ async def client( # proving server is still responsive result = await session.call_tool("fast", read_timeout_seconds=None) assert result.content == [TextContent(type="text", text="fast 3")] - scope.cancel() # pragma: no cover + scope.cancel() # pragma: lax no cover # Run server and client in separate task groups to avoid cancellation server_writer, server_reader = anyio.create_memory_object_stream[SessionMessage](1) diff --git a/tests/issues/test_973_url_decoding.py b/tests/issues/test_973_url_decoding.py new file mode 100644 index 000000000..01cf222b9 --- /dev/null +++ b/tests/issues/test_973_url_decoding.py @@ -0,0 +1,78 @@ +"""Test that URL-encoded parameters are decoded in resource templates. + +Regression test for https://github.com/modelcontextprotocol/python-sdk/issues/973 +""" + +from mcp.server.mcpserver.resources import ResourceTemplate + + +def test_template_matches_decodes_space(): + """Test that %20 is decoded to space.""" + + def search(query: str) -> str: # pragma: no cover + return f"Results for: {query}" + + template = ResourceTemplate.from_function( + fn=search, + uri_template="search://{query}", + name="search", + ) + + params = template.matches("search://hello%20world") + assert params is not None + assert params["query"] == "hello world" + + +def test_template_matches_decodes_accented_characters(): + """Test that %C3%A9 is decoded to e with accent.""" + + def search(query: str) -> str: # pragma: no cover + return f"Results for: {query}" + + template = ResourceTemplate.from_function( + fn=search, + uri_template="search://{query}", + name="search", + ) + + params = template.matches("search://caf%C3%A9") + assert params is not None + assert params["query"] == "café" + + +def test_template_matches_decodes_complex_phrase(): + """Test complex French phrase from the original issue.""" + + def search(query: str) -> str: # pragma: no cover + return f"Results for: {query}" + + template = ResourceTemplate.from_function( + fn=search, + uri_template="search://{query}", + name="search", + ) + + params = template.matches("search://stick%20correcteur%20teint%C3%A9%20anti-imperfections") + assert params is not None + assert params["query"] == "stick correcteur teinté anti-imperfections" + + +def test_template_matches_preserves_plus_sign(): + """Test that plus sign remains as plus (not converted to space). + + In URI encoding, %20 is space. Plus-as-space is only for + application/x-www-form-urlencoded (HTML forms). + """ + + def search(query: str) -> str: # pragma: no cover + return f"Results for: {query}" + + template = ResourceTemplate.from_function( + fn=search, + uri_template="search://{query}", + name="search", + ) + + params = template.matches("search://hello+world") + assert params is not None + assert params["query"] == "hello+world" diff --git a/tests/issues/test_malformed_input.py b/tests/issues/test_malformed_input.py index 078beb7a5..da586f309 100644 --- a/tests/issues/test_malformed_input.py +++ b/tests/issues/test_malformed_input.py @@ -1,27 +1,18 @@ # Claude Debug """Test for HackerOne vulnerability report #3156202 - malformed input DOS.""" -from typing import Any - import anyio import pytest from mcp.server.models import InitializationOptions from mcp.server.session import ServerSession from mcp.shared.message import SessionMessage -from mcp.types import ( - INVALID_PARAMS, - JSONRPCError, - JSONRPCMessage, - JSONRPCRequest, - ServerCapabilities, -) +from mcp.types import INVALID_PARAMS, JSONRPCError, JSONRPCMessage, JSONRPCRequest, ServerCapabilities @pytest.mark.anyio async def test_malformed_initialize_request_does_not_crash_server(): - """ - Test that malformed initialize requests return proper error responses + """Test that malformed initialize requests return proper error responses instead of crashing the server (HackerOne #3156202). """ # Create in-memory streams for testing @@ -38,7 +29,7 @@ async def test_malformed_initialize_request_does_not_crash_server(): ) # Wrap in session message - request_message = SessionMessage(message=JSONRPCMessage(malformed_request)) + request_message = SessionMessage(message=malformed_request) # Start a server session async with ServerSession( @@ -59,7 +50,7 @@ async def test_malformed_initialize_request_does_not_crash_server(): # Check that we received an error response instead of a crash try: response_message = write_receive_stream.receive_nowait() - response = response_message.message.root + response = response_message.message # Verify it's a proper JSON-RPC error response assert isinstance(response, JSONRPCError) @@ -76,14 +67,14 @@ async def test_malformed_initialize_request_does_not_crash_server(): method="tools/call", # params=None # Missing required params ) - another_request_message = SessionMessage(message=JSONRPCMessage(another_malformed_request)) + another_request_message = SessionMessage(message=another_malformed_request) await read_send_stream.send(another_request_message) await anyio.sleep(0.1) # Should get another error response, not a crash second_response_message = write_receive_stream.receive_nowait() - second_response = second_response_message.message.root + second_response = second_response_message.message assert isinstance(second_response, JSONRPCError) assert second_response.id == "test_id_2" @@ -91,7 +82,7 @@ async def test_malformed_initialize_request_does_not_crash_server(): except anyio.WouldBlock: # pragma: no cover pytest.fail("No response received - server likely crashed") - finally: # pragma: no cover + finally: # pragma: lax no cover # Close all streams to ensure proper cleanup await read_send_stream.aclose() await write_send_stream.aclose() @@ -101,9 +92,7 @@ async def test_malformed_initialize_request_does_not_crash_server(): @pytest.mark.anyio async def test_multiple_concurrent_malformed_requests(): - """ - Test that multiple concurrent malformed requests don't crash the server. - """ + """Test that multiple concurrent malformed requests don't crash the server.""" # Create in-memory streams for testing read_send_stream, read_receive_stream = anyio.create_memory_object_stream[SessionMessage | Exception](100) write_send_stream, write_receive_stream = anyio.create_memory_object_stream[SessionMessage](100) @@ -128,7 +117,7 @@ async def test_multiple_concurrent_malformed_requests(): method="initialize", # params=None # Missing required params ) - request_message = SessionMessage(message=JSONRPCMessage(malformed_request)) + request_message = SessionMessage(message=malformed_request) malformed_requests.append(request_message) # Send all requests @@ -139,11 +128,11 @@ async def test_multiple_concurrent_malformed_requests(): await anyio.sleep(0.2) # Verify we get error responses for all requests - error_responses: list[Any] = [] + error_responses: list[JSONRPCMessage] = [] try: while True: response_message = write_receive_stream.receive_nowait() - error_responses.append(response_message.message.root) + error_responses.append(response_message.message) except anyio.WouldBlock: pass # No more messages @@ -154,7 +143,7 @@ async def test_multiple_concurrent_malformed_requests(): assert isinstance(response, JSONRPCError) assert response.id == f"malformed_{i}" assert response.error.code == INVALID_PARAMS - finally: # pragma: no cover + finally: # pragma: lax no cover # Close all streams to ensure proper cleanup await read_send_stream.aclose() await write_send_stream.aclose() diff --git a/tests/server/fastmcp/resources/__init__.py b/tests/server/auth/__init__.py similarity index 100% rename from tests/server/fastmcp/resources/__init__.py rename to tests/server/auth/__init__.py diff --git a/tests/server/fastmcp/servers/__init__.py b/tests/server/auth/middleware/__init__.py similarity index 100% rename from tests/server/fastmcp/servers/__init__.py rename to tests/server/auth/middleware/__init__.py diff --git a/tests/server/auth/middleware/test_auth_context.py b/tests/server/auth/middleware/test_auth_context.py index 1cca4df5a..66481bcf7 100644 --- a/tests/server/auth/middleware/test_auth_context.py +++ b/tests/server/auth/middleware/test_auth_context.py @@ -1,6 +1,4 @@ -""" -Tests for the AuthContext middleware components. -""" +"""Tests for the AuthContext middleware components.""" import time @@ -47,76 +45,75 @@ def valid_access_token() -> AccessToken: @pytest.mark.anyio -class TestAuthContextMiddleware: - """Tests for the AuthContextMiddleware class.""" +async def test_auth_context_middleware_with_authenticated_user(valid_access_token: AccessToken): + """Test middleware with an authenticated user in scope.""" + app = MockApp() + middleware = AuthContextMiddleware(app) - async def test_with_authenticated_user(self, valid_access_token: AccessToken): - """Test middleware with an authenticated user in scope.""" - app = MockApp() - middleware = AuthContextMiddleware(app) + # Create an authenticated user + user = AuthenticatedUser(valid_access_token) - # Create an authenticated user - user = AuthenticatedUser(valid_access_token) + scope: Scope = {"type": "http", "user": user} - scope: Scope = {"type": "http", "user": user} + # Create dummy async functions for receive and send + async def receive() -> Message: # pragma: no cover + return {"type": "http.request"} - # Create dummy async functions for receive and send - async def receive() -> Message: # pragma: no cover - return {"type": "http.request"} + async def send(message: Message) -> None: # pragma: no cover + pass - async def send(message: Message) -> None: # pragma: no cover - pass + # Verify context is empty before middleware + assert auth_context_var.get() is None + assert get_access_token() is None - # Verify context is empty before middleware - assert auth_context_var.get() is None - assert get_access_token() is None + # Run the middleware + await middleware(scope, receive, send) - # Run the middleware - await middleware(scope, receive, send) + # Verify the app was called + assert app.called + assert app.scope == scope + assert app.receive == receive + assert app.send == send - # Verify the app was called - assert app.called - assert app.scope == scope - assert app.receive == receive - assert app.send == send + # Verify the access token was available during the call + assert app.access_token_during_call == valid_access_token - # Verify the access token was available during the call - assert app.access_token_during_call == valid_access_token + # Verify context is reset after middleware + assert auth_context_var.get() is None + assert get_access_token() is None - # Verify context is reset after middleware - assert auth_context_var.get() is None - assert get_access_token() is None - async def test_with_no_user(self): - """Test middleware with no user in scope.""" - app = MockApp() - middleware = AuthContextMiddleware(app) +@pytest.mark.anyio +async def test_auth_context_middleware_with_no_user(): + """Test middleware with no user in scope.""" + app = MockApp() + middleware = AuthContextMiddleware(app) - scope: Scope = {"type": "http"} # No user + scope: Scope = {"type": "http"} # No user - # Create dummy async functions for receive and send - async def receive() -> Message: # pragma: no cover - return {"type": "http.request"} + # Create dummy async functions for receive and send + async def receive() -> Message: # pragma: no cover + return {"type": "http.request"} - async def send(message: Message) -> None: # pragma: no cover - pass + async def send(message: Message) -> None: # pragma: no cover + pass - # Verify context is empty before middleware - assert auth_context_var.get() is None - assert get_access_token() is None + # Verify context is empty before middleware + assert auth_context_var.get() is None + assert get_access_token() is None - # Run the middleware - await middleware(scope, receive, send) + # Run the middleware + await middleware(scope, receive, send) - # Verify the app was called - assert app.called - assert app.scope == scope - assert app.receive == receive - assert app.send == send + # Verify the app was called + assert app.called + assert app.scope == scope + assert app.receive == receive + assert app.send == send - # Verify the access token was not available during the call - assert app.access_token_during_call is None + # Verify the access token was not available during the call + assert app.access_token_during_call is None - # Verify context is still empty after middleware - assert auth_context_var.get() is None - assert get_access_token() is None + # Verify context is still empty after middleware + assert auth_context_var.get() is None + assert get_access_token() is None diff --git a/tests/server/auth/middleware/test_bearer_auth.py b/tests/server/auth/middleware/test_bearer_auth.py index e13ab9639..bd14e294c 100644 --- a/tests/server/auth/middleware/test_bearer_auth.py +++ b/tests/server/auth/middleware/test_bearer_auth.py @@ -1,6 +1,4 @@ -""" -Tests for the BearerAuth middleware components. -""" +"""Tests for the BearerAuth middleware components.""" import time from typing import Any, cast diff --git a/tests/server/auth/test_error_handling.py b/tests/server/auth/test_error_handling.py index f331b2cb2..7c5c43582 100644 --- a/tests/server/auth/test_error_handling.py +++ b/tests/server/auth/test_error_handling.py @@ -1,9 +1,10 @@ -""" -Tests for OAuth error handling in the auth handlers. -""" +"""Tests for OAuth error handling in the auth handlers.""" +import base64 +import hashlib +import secrets import unittest.mock -from typing import TYPE_CHECKING, Any +from typing import Any from urllib.parse import parse_qs, urlparse import httpx @@ -14,12 +15,8 @@ from mcp.server.auth.provider import AuthorizeError, RegistrationError, TokenError from mcp.server.auth.routes import create_auth_routes - -# TODO(Marcelo): This TYPE_CHECKING shouldn't be here, but pytest doesn't seem to get the module correctly. -if TYPE_CHECKING: - from ...server.fastmcp.auth.test_auth_integration import MockOAuthProvider -else: - from tests.server.fastmcp.auth.test_auth_integration import MockOAuthProvider +from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions +from tests.server.mcpserver.auth.test_auth_integration import MockOAuthProvider @pytest.fixture @@ -30,8 +27,6 @@ def oauth_provider(): @pytest.fixture def app(oauth_provider: MockOAuthProvider): - from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions - # Enable client registration client_registration_options = ClientRegistrationOptions(enabled=True) revocation_options = RevocationOptions(enabled=True) @@ -58,10 +53,6 @@ def client(app: Starlette): @pytest.fixture def pkce_challenge(): """Create a PKCE challenge with code_verifier and code_challenge.""" - import base64 - import hashlib - import secrets - # Generate a code verifier code_verifier = secrets.token_urlsafe(64)[:128] @@ -92,176 +83,120 @@ async def registered_client(client: httpx.AsyncClient) -> dict[str, Any]: return client_info -class TestRegistrationErrorHandling: - @pytest.mark.anyio - async def test_registration_error_handling(self, client: httpx.AsyncClient, oauth_provider: MockOAuthProvider): - # Mock the register_client method to raise a registration error - with unittest.mock.patch.object( - oauth_provider, - "register_client", - side_effect=RegistrationError( - error="invalid_redirect_uri", - error_description="The redirect URI is invalid", - ), - ): - # Prepare a client registration request - client_data = { - "redirect_uris": ["https://client.example.com/callback"], - "token_endpoint_auth_method": "client_secret_post", - "grant_types": ["authorization_code", "refresh_token"], - "response_types": ["code"], - "client_name": "Test Client", - } - - # Send the registration request - response = await client.post( - "/register", - json=client_data, - ) - - # Verify the response - assert response.status_code == 400, response.content - data = response.json() - assert data["error"] == "invalid_redirect_uri" - assert data["error_description"] == "The redirect URI is invalid" - - -class TestAuthorizeErrorHandling: - @pytest.mark.anyio - async def test_authorize_error_handling( - self, - client: httpx.AsyncClient, - oauth_provider: MockOAuthProvider, - registered_client: dict[str, Any], - pkce_challenge: dict[str, str], - ): - # Mock the authorize method to raise an authorize error - with unittest.mock.patch.object( - oauth_provider, - "authorize", - side_effect=AuthorizeError(error="access_denied", error_description="The user denied the request"), - ): - # Register the client - client_id = registered_client["client_id"] - redirect_uri = registered_client["redirect_uris"][0] - - # Prepare an authorization request - params = { - "client_id": client_id, - "redirect_uri": redirect_uri, - "response_type": "code", - "code_challenge": pkce_challenge["code_challenge"], - "code_challenge_method": "S256", - "state": "test_state", - } - - # Send the authorization request - response = await client.get("/authorize", params=params) - - # Verify the response is a redirect with error parameters - assert response.status_code == 302 - redirect_url = response.headers["location"] - parsed_url = urlparse(redirect_url) - query_params = parse_qs(parsed_url.query) - - assert query_params["error"][0] == "access_denied" - assert "error_description" in query_params - assert query_params["state"][0] == "test_state" - - -class TestTokenErrorHandling: - @pytest.mark.anyio - async def test_token_error_handling_auth_code( - self, - client: httpx.AsyncClient, - oauth_provider: MockOAuthProvider, - registered_client: dict[str, Any], - pkce_challenge: dict[str, str], +@pytest.mark.anyio +async def test_registration_error_handling(client: httpx.AsyncClient, oauth_provider: MockOAuthProvider): + # Mock the register_client method to raise a registration error + with unittest.mock.patch.object( + oauth_provider, + "register_client", + side_effect=RegistrationError( + error="invalid_redirect_uri", + error_description="The redirect URI is invalid", + ), ): - # Register the client and get an auth code - client_id = registered_client["client_id"] - client_secret = registered_client["client_secret"] - redirect_uri = registered_client["redirect_uris"][0] - - # First get an authorization code - auth_response = await client.get( - "/authorize", - params={ - "client_id": client_id, - "redirect_uri": redirect_uri, - "response_type": "code", - "code_challenge": pkce_challenge["code_challenge"], - "code_challenge_method": "S256", - "state": "test_state", - }, + # Prepare a client registration request + client_data = { + "redirect_uris": ["https://client.example.com/callback"], + "token_endpoint_auth_method": "client_secret_post", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "client_name": "Test Client", + } + + # Send the registration request + response = await client.post( + "/register", + json=client_data, ) - redirect_url = auth_response.headers["location"] - parsed_url = urlparse(redirect_url) - query_params = parse_qs(parsed_url.query) - code = query_params["code"][0] - - # Mock the exchange_authorization_code method to raise a token error - with unittest.mock.patch.object( - oauth_provider, - "exchange_authorization_code", - side_effect=TokenError( - error="invalid_grant", - error_description="The authorization code is invalid", - ), - ): - # Try to exchange the code for tokens - token_response = await client.post( - "/token", - data={ - "grant_type": "authorization_code", - "code": code, - "redirect_uri": redirect_uri, - "client_id": client_id, - "client_secret": client_secret, - "code_verifier": pkce_challenge["code_verifier"], - }, - ) - - # Verify the response - assert token_response.status_code == 400 - data = token_response.json() - assert data["error"] == "invalid_grant" - assert data["error_description"] == "The authorization code is invalid" - - @pytest.mark.anyio - async def test_token_error_handling_refresh_token( - self, - client: httpx.AsyncClient, - oauth_provider: MockOAuthProvider, - registered_client: dict[str, Any], - pkce_challenge: dict[str, str], + # Verify the response + assert response.status_code == 400, response.content + data = response.json() + assert data["error"] == "invalid_redirect_uri" + assert data["error_description"] == "The redirect URI is invalid" + + +@pytest.mark.anyio +async def test_authorize_error_handling( + client: httpx.AsyncClient, + oauth_provider: MockOAuthProvider, + registered_client: dict[str, Any], + pkce_challenge: dict[str, str], +): + # Mock the authorize method to raise an authorize error + with unittest.mock.patch.object( + oauth_provider, + "authorize", + side_effect=AuthorizeError(error="access_denied", error_description="The user denied the request"), ): - # Register the client and get tokens + # Register the client client_id = registered_client["client_id"] - client_secret = registered_client["client_secret"] redirect_uri = registered_client["redirect_uris"][0] - # First get an authorization code - auth_response = await client.get( - "/authorize", - params={ - "client_id": client_id, - "redirect_uri": redirect_uri, - "response_type": "code", - "code_challenge": pkce_challenge["code_challenge"], - "code_challenge_method": "S256", - "state": "test_state", - }, - ) - assert auth_response.status_code == 302, auth_response.content - - redirect_url = auth_response.headers["location"] + # Prepare an authorization request + params = { + "client_id": client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "code_challenge": pkce_challenge["code_challenge"], + "code_challenge_method": "S256", + "state": "test_state", + } + + # Send the authorization request + response = await client.get("/authorize", params=params) + + # Verify the response is a redirect with error parameters + assert response.status_code == 302 + redirect_url = response.headers["location"] parsed_url = urlparse(redirect_url) query_params = parse_qs(parsed_url.query) - code = query_params["code"][0] - # Exchange the code for tokens + assert query_params["error"][0] == "access_denied" + assert "error_description" in query_params + assert query_params["state"][0] == "test_state" + + +@pytest.mark.anyio +async def test_token_error_handling_auth_code( + client: httpx.AsyncClient, + oauth_provider: MockOAuthProvider, + registered_client: dict[str, Any], + pkce_challenge: dict[str, str], +): + # Register the client and get an auth code + client_id = registered_client["client_id"] + client_secret = registered_client["client_secret"] + redirect_uri = registered_client["redirect_uris"][0] + + # First get an authorization code + auth_response = await client.get( + "/authorize", + params={ + "client_id": client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "code_challenge": pkce_challenge["code_challenge"], + "code_challenge_method": "S256", + "state": "test_state", + }, + ) + + redirect_url = auth_response.headers["location"] + parsed_url = urlparse(redirect_url) + query_params = parse_qs(parsed_url.query) + code = query_params["code"][0] + + # Mock the exchange_authorization_code method to raise a token error + with unittest.mock.patch.object( + oauth_provider, + "exchange_authorization_code", + side_effect=TokenError( + error="invalid_grant", + error_description="The authorization code is invalid", + ), + ): + # Try to exchange the code for tokens token_response = await client.post( "/token", data={ @@ -274,31 +209,82 @@ async def test_token_error_handling_refresh_token( }, ) - tokens = token_response.json() - refresh_token = tokens["refresh_token"] - - # Mock the exchange_refresh_token method to raise a token error - with unittest.mock.patch.object( - oauth_provider, - "exchange_refresh_token", - side_effect=TokenError( - error="invalid_scope", - error_description="The requested scope is invalid", - ), - ): - # Try to use the refresh token - refresh_response = await client.post( - "/token", - data={ - "grant_type": "refresh_token", - "refresh_token": refresh_token, - "client_id": client_id, - "client_secret": client_secret, - }, - ) - - # Verify the response - assert refresh_response.status_code == 400 - data = refresh_response.json() - assert data["error"] == "invalid_scope" - assert data["error_description"] == "The requested scope is invalid" + # Verify the response + assert token_response.status_code == 400 + data = token_response.json() + assert data["error"] == "invalid_grant" + assert data["error_description"] == "The authorization code is invalid" + + +@pytest.mark.anyio +async def test_token_error_handling_refresh_token( + client: httpx.AsyncClient, + oauth_provider: MockOAuthProvider, + registered_client: dict[str, Any], + pkce_challenge: dict[str, str], +): + # Register the client and get tokens + client_id = registered_client["client_id"] + client_secret = registered_client["client_secret"] + redirect_uri = registered_client["redirect_uris"][0] + + # First get an authorization code + auth_response = await client.get( + "/authorize", + params={ + "client_id": client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "code_challenge": pkce_challenge["code_challenge"], + "code_challenge_method": "S256", + "state": "test_state", + }, + ) + assert auth_response.status_code == 302, auth_response.content + + redirect_url = auth_response.headers["location"] + parsed_url = urlparse(redirect_url) + query_params = parse_qs(parsed_url.query) + code = query_params["code"][0] + + # Exchange the code for tokens + token_response = await client.post( + "/token", + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": client_id, + "client_secret": client_secret, + "code_verifier": pkce_challenge["code_verifier"], + }, + ) + + tokens = token_response.json() + refresh_token = tokens["refresh_token"] + + # Mock the exchange_refresh_token method to raise a token error + with unittest.mock.patch.object( + oauth_provider, + "exchange_refresh_token", + side_effect=TokenError( + error="invalid_scope", + error_description="The requested scope is invalid", + ), + ): + # Try to use the refresh token + refresh_response = await client.post( + "/token", + data={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": client_id, + "client_secret": client_secret, + }, + ) + + # Verify the response + assert refresh_response.status_code == 400 + data = refresh_response.json() + assert data["error"] == "invalid_scope" + assert data["error_description"] == "The requested scope is invalid" diff --git a/tests/server/auth/test_protected_resource.py b/tests/server/auth/test_protected_resource.py index 82af16c5b..413a80276 100644 --- a/tests/server/auth/test_protected_resource.py +++ b/tests/server/auth/test_protected_resource.py @@ -1,6 +1,6 @@ -""" -Integration tests for MCP Oauth Protected Resource. -""" +"""Integration tests for MCP Oauth Protected Resource.""" + +from urllib.parse import urlparse import httpx import pytest @@ -105,94 +105,94 @@ async def test_metadata_endpoint_without_path(root_resource_client: httpx.AsyncC ) -class TestMetadataUrlConstruction: - """Test URL construction utility function.""" - - def test_url_without_path(self): - """Test URL construction for resource without path component.""" - resource_url = AnyHttpUrl("https://example.com") - result = build_resource_metadata_url(resource_url) - assert str(result) == "https://example.com/.well-known/oauth-protected-resource" - - def test_url_with_path_component(self): - """Test URL construction for resource with path component.""" - resource_url = AnyHttpUrl("https://example.com/mcp") - result = build_resource_metadata_url(resource_url) - assert str(result) == "https://example.com/.well-known/oauth-protected-resource/mcp" - - def test_url_with_trailing_slash_only(self): - """Test URL construction for resource with trailing slash only.""" - resource_url = AnyHttpUrl("https://example.com/") - result = build_resource_metadata_url(resource_url) - # Trailing slash should be treated as empty path - assert str(result) == "https://example.com/.well-known/oauth-protected-resource" - - @pytest.mark.parametrize( - "resource_url,expected_url", - [ - ("https://example.com", "https://example.com/.well-known/oauth-protected-resource"), - ("https://example.com/", "https://example.com/.well-known/oauth-protected-resource"), - ("https://example.com/mcp", "https://example.com/.well-known/oauth-protected-resource/mcp"), - ("http://localhost:8001/mcp", "http://localhost:8001/.well-known/oauth-protected-resource/mcp"), - ], - ) - def test_various_resource_configurations(self, resource_url: str, expected_url: str): - """Test URL construction with various resource configurations.""" - result = build_resource_metadata_url(AnyHttpUrl(resource_url)) - assert str(result) == expected_url +# Tests for URL construction utility function + + +def test_metadata_url_construction_url_without_path(): + """Test URL construction for resource without path component.""" + resource_url = AnyHttpUrl("https://example.com") + result = build_resource_metadata_url(resource_url) + assert str(result) == "https://example.com/.well-known/oauth-protected-resource" + + +def test_metadata_url_construction_url_with_path_component(): + """Test URL construction for resource with path component.""" + resource_url = AnyHttpUrl("https://example.com/mcp") + result = build_resource_metadata_url(resource_url) + assert str(result) == "https://example.com/.well-known/oauth-protected-resource/mcp" -class TestRouteConsistency: - """Test consistency between URL generation and route registration.""" +def test_metadata_url_construction_url_with_trailing_slash_only(): + """Test URL construction for resource with trailing slash only.""" + resource_url = AnyHttpUrl("https://example.com/") + result = build_resource_metadata_url(resource_url) + # Trailing slash should be treated as empty path + assert str(result) == "https://example.com/.well-known/oauth-protected-resource" - def test_route_path_matches_metadata_url(self): - """Test that route path matches the generated metadata URL.""" - resource_url = AnyHttpUrl("https://example.com/mcp") - # Generate metadata URL - metadata_url = build_resource_metadata_url(resource_url) +@pytest.mark.parametrize( + "resource_url,expected_url", + [ + ("https://example.com", "https://example.com/.well-known/oauth-protected-resource"), + ("https://example.com/", "https://example.com/.well-known/oauth-protected-resource"), + ("https://example.com/mcp", "https://example.com/.well-known/oauth-protected-resource/mcp"), + ("http://localhost:8001/mcp", "http://localhost:8001/.well-known/oauth-protected-resource/mcp"), + ], +) +def test_metadata_url_construction_various_resource_configurations(resource_url: str, expected_url: str): + """Test URL construction with various resource configurations.""" + result = build_resource_metadata_url(AnyHttpUrl(resource_url)) + assert str(result) == expected_url - # Create routes - routes = create_protected_resource_routes( - resource_url=resource_url, - authorization_servers=[AnyHttpUrl("https://auth.example.com")], - ) - # Extract path from metadata URL - from urllib.parse import urlparse +# Tests for consistency between URL generation and route registration - metadata_path = urlparse(str(metadata_url)).path - # Verify consistency - assert len(routes) == 1 - assert routes[0].path == metadata_path +def test_route_consistency_route_path_matches_metadata_url(): + """Test that route path matches the generated metadata URL.""" + resource_url = AnyHttpUrl("https://example.com/mcp") - @pytest.mark.parametrize( - "resource_url,expected_path", - [ - ("https://example.com", "/.well-known/oauth-protected-resource"), - ("https://example.com/", "/.well-known/oauth-protected-resource"), - ("https://example.com/mcp", "/.well-known/oauth-protected-resource/mcp"), - ], + # Generate metadata URL + metadata_url = build_resource_metadata_url(resource_url) + + # Create routes + routes = create_protected_resource_routes( + resource_url=resource_url, + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + ) + + # Extract path from metadata URL + metadata_path = urlparse(str(metadata_url)).path + + # Verify consistency + assert len(routes) == 1 + assert routes[0].path == metadata_path + + +@pytest.mark.parametrize( + "resource_url,expected_path", + [ + ("https://example.com", "/.well-known/oauth-protected-resource"), + ("https://example.com/", "/.well-known/oauth-protected-resource"), + ("https://example.com/mcp", "/.well-known/oauth-protected-resource/mcp"), + ], +) +def test_route_consistency_consistent_paths_for_various_resources(resource_url: str, expected_path: str): + """Test that URL generation and route creation are consistent.""" + resource_url_obj = AnyHttpUrl(resource_url) + + # Test URL generation + metadata_url = build_resource_metadata_url(resource_url_obj) + url_path = urlparse(str(metadata_url)).path + + # Test route creation + routes = create_protected_resource_routes( + resource_url=resource_url_obj, + authorization_servers=[AnyHttpUrl("https://auth.example.com")], ) - def test_consistent_paths_for_various_resources(self, resource_url: str, expected_path: str): - """Test that URL generation and route creation are consistent.""" - resource_url_obj = AnyHttpUrl(resource_url) - - # Test URL generation - metadata_url = build_resource_metadata_url(resource_url_obj) - from urllib.parse import urlparse - - url_path = urlparse(str(metadata_url)).path - - # Test route creation - routes = create_protected_resource_routes( - resource_url=resource_url_obj, - authorization_servers=[AnyHttpUrl("https://auth.example.com")], - ) - route_path = routes[0].path - - # Both should match expected path - assert url_path == expected_path - assert route_path == expected_path - assert url_path == route_path + route_path = routes[0].path + + # Both should match expected path + assert url_path == expected_path + assert route_path == expected_path + assert url_path == route_path diff --git a/tests/server/auth/test_provider.py b/tests/server/auth/test_provider.py index 7fe621349..aaaeb413a 100644 --- a/tests/server/auth/test_provider.py +++ b/tests/server/auth/test_provider.py @@ -1,77 +1,79 @@ -""" -Tests for mcp.server.auth.provider module. -""" +"""Tests for mcp.server.auth.provider module.""" from mcp.server.auth.provider import construct_redirect_uri -class TestConstructRedirectUri: - """Tests for the construct_redirect_uri function.""" +def test_construct_redirect_uri_no_existing_params(): + """Test construct_redirect_uri with no existing query parameters.""" + base_uri = "http://localhost:8000/callback" + result = construct_redirect_uri(base_uri, code="auth_code", state="test_state") - def test_construct_redirect_uri_no_existing_params(self): - """Test construct_redirect_uri with no existing query parameters.""" - base_uri = "http://localhost:8000/callback" - result = construct_redirect_uri(base_uri, code="auth_code", state="test_state") + assert "http://localhost:8000/callback?code=auth_code&state=test_state" == result - assert "http://localhost:8000/callback?code=auth_code&state=test_state" == result - def test_construct_redirect_uri_with_existing_params(self): - """Test construct_redirect_uri with existing query parameters (regression test for #1279).""" - base_uri = "http://localhost:8000/callback?session_id=1234" - result = construct_redirect_uri(base_uri, code="auth_code", state="test_state") +def test_construct_redirect_uri_with_existing_params(): + """Test construct_redirect_uri with existing query parameters (regression test for #1279).""" + base_uri = "http://localhost:8000/callback?session_id=1234" + result = construct_redirect_uri(base_uri, code="auth_code", state="test_state") - # Should preserve existing params and add new ones - assert "session_id=1234" in result - assert "code=auth_code" in result - assert "state=test_state" in result - assert result.startswith("http://localhost:8000/callback?") + # Should preserve existing params and add new ones + assert "session_id=1234" in result + assert "code=auth_code" in result + assert "state=test_state" in result + assert result.startswith("http://localhost:8000/callback?") - def test_construct_redirect_uri_multiple_existing_params(self): - """Test construct_redirect_uri with multiple existing query parameters.""" - base_uri = "http://localhost:8000/callback?session_id=1234&user=test" - result = construct_redirect_uri(base_uri, code="auth_code") - assert "session_id=1234" in result - assert "user=test" in result - assert "code=auth_code" in result +def test_construct_redirect_uri_multiple_existing_params(): + """Test construct_redirect_uri with multiple existing query parameters.""" + base_uri = "http://localhost:8000/callback?session_id=1234&user=test" + result = construct_redirect_uri(base_uri, code="auth_code") - def test_construct_redirect_uri_with_none_values(self): - """Test construct_redirect_uri filters out None values.""" - base_uri = "http://localhost:8000/callback" - result = construct_redirect_uri(base_uri, code="auth_code", state=None) + assert "session_id=1234" in result + assert "user=test" in result + assert "code=auth_code" in result - assert result == "http://localhost:8000/callback?code=auth_code" - assert "state" not in result - def test_construct_redirect_uri_empty_params(self): - """Test construct_redirect_uri with no additional parameters.""" - base_uri = "http://localhost:8000/callback?existing=param" - result = construct_redirect_uri(base_uri) +def test_construct_redirect_uri_with_none_values(): + """Test construct_redirect_uri filters out None values.""" + base_uri = "http://localhost:8000/callback" + result = construct_redirect_uri(base_uri, code="auth_code", state=None) - assert result == "http://localhost:8000/callback?existing=param" + assert result == "http://localhost:8000/callback?code=auth_code" + assert "state" not in result - def test_construct_redirect_uri_duplicate_param_names(self): - """Test construct_redirect_uri when adding param that already exists.""" - base_uri = "http://localhost:8000/callback?code=existing" - result = construct_redirect_uri(base_uri, code="new_code") - # Should contain both values (this is expected behavior of parse_qs/urlencode) - assert "code=existing" in result - assert "code=new_code" in result +def test_construct_redirect_uri_empty_params(): + """Test construct_redirect_uri with no additional parameters.""" + base_uri = "http://localhost:8000/callback?existing=param" + result = construct_redirect_uri(base_uri) - def test_construct_redirect_uri_multivalued_existing_params(self): - """Test construct_redirect_uri with existing multi-valued parameters.""" - base_uri = "http://localhost:8000/callback?scope=read&scope=write" - result = construct_redirect_uri(base_uri, code="auth_code") + assert result == "http://localhost:8000/callback?existing=param" - assert "scope=read" in result - assert "scope=write" in result - assert "code=auth_code" in result - def test_construct_redirect_uri_encoded_values(self): - """Test construct_redirect_uri handles URL encoding properly.""" - base_uri = "http://localhost:8000/callback" - result = construct_redirect_uri(base_uri, state="test state with spaces") +def test_construct_redirect_uri_duplicate_param_names(): + """Test construct_redirect_uri when adding param that already exists.""" + base_uri = "http://localhost:8000/callback?code=existing" + result = construct_redirect_uri(base_uri, code="new_code") - # urlencode uses + for spaces by default - assert "state=test+state+with+spaces" in result + # Should contain both values (this is expected behavior of parse_qs/urlencode) + assert "code=existing" in result + assert "code=new_code" in result + + +def test_construct_redirect_uri_multivalued_existing_params(): + """Test construct_redirect_uri with existing multi-valued parameters.""" + base_uri = "http://localhost:8000/callback?scope=read&scope=write" + result = construct_redirect_uri(base_uri, code="auth_code") + + assert "scope=read" in result + assert "scope=write" in result + assert "code=auth_code" in result + + +def test_construct_redirect_uri_encoded_values(): + """Test construct_redirect_uri handles URL encoding properly.""" + base_uri = "http://localhost:8000/callback" + result = construct_redirect_uri(base_uri, state="test state with spaces") + + # urlencode uses + for spaces by default + assert "state=test+state+with+spaces" in result diff --git a/tests/server/auth/test_routes.py b/tests/server/auth/test_routes.py new file mode 100644 index 000000000..3d13b5ba5 --- /dev/null +++ b/tests/server/auth/test_routes.py @@ -0,0 +1,47 @@ +import pytest +from pydantic import AnyHttpUrl + +from mcp.server.auth.routes import validate_issuer_url + + +def test_validate_issuer_url_https_allowed(): + validate_issuer_url(AnyHttpUrl("https://example.com/path")) + + +def test_validate_issuer_url_http_localhost_allowed(): + validate_issuer_url(AnyHttpUrl("http://localhost:8080/path")) + + +def test_validate_issuer_url_http_127_0_0_1_allowed(): + validate_issuer_url(AnyHttpUrl("http://127.0.0.1:8080/path")) + + +def test_validate_issuer_url_http_ipv6_loopback_allowed(): + validate_issuer_url(AnyHttpUrl("http://[::1]:8080/path")) + + +def test_validate_issuer_url_http_non_loopback_rejected(): + with pytest.raises(ValueError, match="Issuer URL must be HTTPS"): + validate_issuer_url(AnyHttpUrl("http://evil.com/path")) + + +def test_validate_issuer_url_http_127_prefix_domain_rejected(): + """A domain like 127.0.0.1.evil.com is not loopback.""" + with pytest.raises(ValueError, match="Issuer URL must be HTTPS"): + validate_issuer_url(AnyHttpUrl("http://127.0.0.1.evil.com/path")) + + +def test_validate_issuer_url_http_127_prefix_subdomain_rejected(): + """A domain like 127.0.0.1something.example.com is not loopback.""" + with pytest.raises(ValueError, match="Issuer URL must be HTTPS"): + validate_issuer_url(AnyHttpUrl("http://127.0.0.1something.example.com/path")) + + +def test_validate_issuer_url_fragment_rejected(): + with pytest.raises(ValueError, match="fragment"): + validate_issuer_url(AnyHttpUrl("https://example.com/path#frag")) + + +def test_validate_issuer_url_query_rejected(): + with pytest.raises(ValueError, match="query"): + validate_issuer_url(AnyHttpUrl("https://example.com/path?q=1")) diff --git a/tests/server/fastmcp/auth/__init__.py b/tests/server/fastmcp/auth/__init__.py deleted file mode 100644 index 64d318ec4..000000000 --- a/tests/server/fastmcp/auth/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Tests for the MCP server auth components. -""" diff --git a/tests/server/lowlevel/test_func_inspection.py b/tests/server/lowlevel/test_func_inspection.py deleted file mode 100644 index 9cb2b561a..000000000 --- a/tests/server/lowlevel/test_func_inspection.py +++ /dev/null @@ -1,292 +0,0 @@ -"""Unit tests for func_inspection module. - -Tests the create_call_wrapper function which determines how to call handler functions -with different parameter signatures and type hints. -""" - -from typing import Any, Generic, TypeVar - -import pytest - -from mcp.server.lowlevel.func_inspection import create_call_wrapper -from mcp.types import ListPromptsRequest, ListResourcesRequest, ListToolsRequest, PaginatedRequestParams - -T = TypeVar("T") - - -@pytest.mark.anyio -async def test_no_params_returns_deprecated_wrapper() -> None: - """Test: def foo() - should call without request.""" - called_without_request = False - - async def handler() -> list[str]: - nonlocal called_without_request - called_without_request = True - return ["test"] - - wrapper = create_call_wrapper(handler, ListPromptsRequest) - - # Wrapper should call handler without passing request - request = ListPromptsRequest(method="prompts/list", params=None) - result = await wrapper(request) - assert called_without_request is True - assert result == ["test"] - - -@pytest.mark.anyio -async def test_param_with_default_returns_deprecated_wrapper() -> None: - """Test: def foo(thing: int = 1) - should call without request.""" - called_without_request = False - - async def handler(thing: int = 1) -> list[str]: - nonlocal called_without_request - called_without_request = True - return [f"test-{thing}"] - - wrapper = create_call_wrapper(handler, ListPromptsRequest) - - # Wrapper should call handler without passing request (uses default value) - request = ListPromptsRequest(method="prompts/list", params=None) - result = await wrapper(request) - assert called_without_request is True - assert result == ["test-1"] - - -@pytest.mark.anyio -async def test_typed_request_param_passes_request() -> None: - """Test: def foo(req: ListPromptsRequest) - should pass request through.""" - received_request = None - - async def handler(req: ListPromptsRequest) -> list[str]: - nonlocal received_request - received_request = req - return ["test"] - - wrapper = create_call_wrapper(handler, ListPromptsRequest) - - # Wrapper should pass request to handler - request = ListPromptsRequest(method="prompts/list", params=PaginatedRequestParams(cursor="test-cursor")) - await wrapper(request) - - assert received_request is not None - assert received_request is request - params = getattr(received_request, "params", None) - assert params is not None - assert params.cursor == "test-cursor" - - -@pytest.mark.anyio -async def test_typed_request_with_default_param_passes_request() -> None: - """Test: def foo(req: ListPromptsRequest, thing: int = 1) - should pass request through.""" - received_request = None - received_thing = None - - async def handler(req: ListPromptsRequest, thing: int = 1) -> list[str]: - nonlocal received_request, received_thing - received_request = req - received_thing = thing - return ["test"] - - wrapper = create_call_wrapper(handler, ListPromptsRequest) - - # Wrapper should pass request to handler - request = ListPromptsRequest(method="prompts/list", params=None) - await wrapper(request) - - assert received_request is request - assert received_thing == 1 # default value - - -@pytest.mark.anyio -async def test_optional_typed_request_with_default_none_is_deprecated() -> None: - """Test: def foo(thing: int = 1, req: ListPromptsRequest | None = None) - old style.""" - called_without_request = False - - async def handler(thing: int = 1, req: ListPromptsRequest | None = None) -> list[str]: - nonlocal called_without_request - called_without_request = True - return ["test"] - - wrapper = create_call_wrapper(handler, ListPromptsRequest) - - # Wrapper should call handler without passing request - request = ListPromptsRequest(method="prompts/list", params=None) - result = await wrapper(request) - assert called_without_request is True - assert result == ["test"] - - -@pytest.mark.anyio -async def test_untyped_request_param_is_deprecated() -> None: - """Test: def foo(req) - should call without request.""" - called = False - - async def handler(req): # type: ignore[no-untyped-def] # pyright: ignore[reportMissingParameterType] # pragma: no cover - nonlocal called - called = True - return ["test"] - - wrapper = create_call_wrapper(handler, ListPromptsRequest) # pyright: ignore[reportUnknownArgumentType] - - # Wrapper should call handler without passing request, which will fail because req is required - request = ListPromptsRequest(method="prompts/list", params=None) - # This will raise TypeError because handler expects 'req' but wrapper doesn't provide it - with pytest.raises(TypeError, match="missing 1 required positional argument"): - await wrapper(request) - - -@pytest.mark.anyio -async def test_any_typed_request_param_is_deprecated() -> None: - """Test: def foo(req: Any) - should call without request.""" - - async def handler(req: Any) -> list[str]: # pragma: no cover - return ["test"] - - wrapper = create_call_wrapper(handler, ListPromptsRequest) - - # Wrapper should call handler without passing request, which will fail because req is required - request = ListPromptsRequest(method="prompts/list", params=None) - # This will raise TypeError because handler expects 'req' but wrapper doesn't provide it - with pytest.raises(TypeError, match="missing 1 required positional argument"): - await wrapper(request) - - -@pytest.mark.anyio -async def test_generic_typed_request_param_is_deprecated() -> None: - """Test: def foo(req: Generic[T]) - should call without request.""" - - async def handler(req: Generic[T]) -> list[str]: # pyright: ignore[reportGeneralTypeIssues] # pragma: no cover - return ["test"] - - wrapper = create_call_wrapper(handler, ListPromptsRequest) - - # Wrapper should call handler without passing request, which will fail because req is required - request = ListPromptsRequest(method="prompts/list", params=None) - # This will raise TypeError because handler expects 'req' but wrapper doesn't provide it - with pytest.raises(TypeError, match="missing 1 required positional argument"): - await wrapper(request) - - -@pytest.mark.anyio -async def test_wrong_typed_request_param_is_deprecated() -> None: - """Test: def foo(req: str) - should call without request.""" - - async def handler(req: str) -> list[str]: # pragma: no cover - return ["test"] - - wrapper = create_call_wrapper(handler, ListPromptsRequest) - - # Wrapper should call handler without passing request, which will fail because req is required - request = ListPromptsRequest(method="prompts/list", params=None) - # This will raise TypeError because handler expects 'req' but wrapper doesn't provide it - with pytest.raises(TypeError, match="missing 1 required positional argument"): - await wrapper(request) - - -@pytest.mark.anyio -async def test_required_param_before_typed_request_attempts_to_pass() -> None: - """Test: def foo(thing: int, req: ListPromptsRequest) - attempts to pass request (will fail at runtime).""" - received_request = None - - async def handler(thing: int, req: ListPromptsRequest) -> list[str]: # pragma: no cover - nonlocal received_request - received_request = req - return ["test"] - - wrapper = create_call_wrapper(handler, ListPromptsRequest) - - # Wrapper will attempt to pass request, but it will fail at runtime - # because 'thing' is required and has no default - request = ListPromptsRequest(method="prompts/list", params=None) - - # This will raise TypeError because 'thing' is missing - with pytest.raises(TypeError, match="missing 1 required positional argument: 'thing'"): - await wrapper(request) - - -@pytest.mark.anyio -async def test_positional_only_param_with_correct_type() -> None: - """Test: def foo(req: ListPromptsRequest, /) - should pass request through.""" - received_request = None - - async def handler(req: ListPromptsRequest, /) -> list[str]: - nonlocal received_request - received_request = req - return ["test"] - - wrapper = create_call_wrapper(handler, ListPromptsRequest) - - # Wrapper should pass request to handler - request = ListPromptsRequest(method="prompts/list", params=None) - await wrapper(request) - - assert received_request is request - - -@pytest.mark.anyio -async def test_keyword_only_param_with_correct_type() -> None: - """Test: def foo(*, req: ListPromptsRequest) - should pass request through.""" - received_request = None - - async def handler(*, req: ListPromptsRequest) -> list[str]: - nonlocal received_request - received_request = req - return ["test"] - - wrapper = create_call_wrapper(handler, ListPromptsRequest) - - # Wrapper should pass request to handler with keyword argument - request = ListPromptsRequest(method="prompts/list", params=None) - await wrapper(request) - - assert received_request is request - - -@pytest.mark.anyio -async def test_different_request_types() -> None: - """Test that wrapper works with different request types.""" - # Test with ListResourcesRequest - received_request = None - - async def handler(req: ListResourcesRequest) -> list[str]: - nonlocal received_request - received_request = req - return ["test"] - - wrapper = create_call_wrapper(handler, ListResourcesRequest) - - request = ListResourcesRequest(method="resources/list", params=None) - await wrapper(request) - - assert received_request is request - - # Test with ListToolsRequest - received_request = None - - async def handler2(req: ListToolsRequest) -> list[str]: - nonlocal received_request - received_request = req - return ["test"] - - wrapper2 = create_call_wrapper(handler2, ListToolsRequest) - - request2 = ListToolsRequest(method="tools/list", params=None) - await wrapper2(request2) - - assert received_request is request2 - - -@pytest.mark.anyio -async def test_mixed_params_with_typed_request() -> None: - """Test: def foo(a: str, req: ListPromptsRequest, b: int = 5) - attempts to pass request.""" - - async def handler(a: str, req: ListPromptsRequest, b: int = 5) -> list[str]: # pragma: no cover - return ["test"] - - wrapper = create_call_wrapper(handler, ListPromptsRequest) - - # Will fail at runtime due to missing 'a' - request = ListPromptsRequest(method="prompts/list", params=None) - - with pytest.raises(TypeError, match="missing 1 required positional argument: 'a'"): - await wrapper(request) diff --git a/tests/server/lowlevel/test_helper_types.py b/tests/server/lowlevel/test_helper_types.py new file mode 100644 index 000000000..e29273d3f --- /dev/null +++ b/tests/server/lowlevel/test_helper_types.py @@ -0,0 +1,59 @@ +"""Test helper_types.py meta field. + +These tests verify the changes made to helper_types.py:11 where we added: + meta: dict[str, Any] | None = field(default=None) + +ReadResourceContents is the return type for resource read handlers. It's used internally +by the low-level server to package resource content before sending it over the MCP protocol. +""" + +from mcp.server.lowlevel.helper_types import ReadResourceContents + + +def test_read_resource_contents_with_metadata(): + """Test that ReadResourceContents accepts meta parameter. + + ReadResourceContents is an internal helper type used by the low-level MCP server. + When a resource is read, the server creates a ReadResourceContents instance that + contains the content, mime type, and now metadata. The low-level server then + extracts the meta field and includes it in the protocol response as _meta. + """ + # Bridge between Resource.meta and MCP protocol _meta field (helper_types.py:11) + metadata = {"version": "1.0", "cached": True} + + contents = ReadResourceContents( + content="test content", + mime_type="text/plain", + meta=metadata, + ) + + assert contents.meta is not None + assert contents.meta == metadata + assert contents.meta["version"] == "1.0" + assert contents.meta["cached"] is True + + +def test_read_resource_contents_without_metadata(): + """Test that ReadResourceContents meta defaults to None.""" + # Ensures backward compatibility - meta defaults to None, _meta omitted from protocol (helper_types.py:11) + contents = ReadResourceContents( + content="test content", + mime_type="text/plain", + ) + + assert contents.meta is None + + +def test_read_resource_contents_with_bytes(): + """Test that ReadResourceContents works with bytes content and meta.""" + # Verifies meta works with both str and bytes content (binary resources like images, PDFs) + metadata = {"encoding": "utf-8"} + + contents = ReadResourceContents( + content=b"binary content", + mime_type="application/octet-stream", + meta=metadata, + ) + + assert contents.content == b"binary content" + assert contents.meta == metadata diff --git a/tests/server/lowlevel/test_server_listing.py b/tests/server/lowlevel/test_server_listing.py index 23ac7e451..2c3d303a9 100644 --- a/tests/server/lowlevel/test_server_listing.py +++ b/tests/server/lowlevel/test_server_listing.py @@ -1,21 +1,16 @@ -"""Basic tests for list_prompts, list_resources, and list_tools decorators without pagination.""" - -import warnings +"""Basic tests for list_prompts, list_resources, and list_tools handlers without pagination.""" import pytest -from pydantic import AnyUrl -from mcp.server import Server +from mcp import Client +from mcp.server import Server, ServerRequestContext from mcp.types import ( - ListPromptsRequest, ListPromptsResult, - ListResourcesRequest, ListResourcesResult, - ListToolsRequest, ListToolsResult, + PaginatedRequestParams, Prompt, Resource, - ServerResult, Tool, ) @@ -23,65 +18,49 @@ @pytest.mark.anyio async def test_list_prompts_basic() -> None: """Test basic prompt listing without pagination.""" - server = Server("test") - test_prompts = [ Prompt(name="prompt1", description="First prompt"), Prompt(name="prompt2", description="Second prompt"), ] - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - - @server.list_prompts() - async def handle_list_prompts() -> list[Prompt]: - return test_prompts - - handler = server.request_handlers[ListPromptsRequest] - request = ListPromptsRequest(method="prompts/list", params=None) - result = await handler(request) + async def handle_list_prompts( + ctx: ServerRequestContext, params: PaginatedRequestParams | None + ) -> ListPromptsResult: + return ListPromptsResult(prompts=test_prompts) - assert isinstance(result, ServerResult) - assert isinstance(result.root, ListPromptsResult) - assert result.root.prompts == test_prompts + server = Server("test", on_list_prompts=handle_list_prompts) + async with Client(server) as client: + result = await client.list_prompts() + assert result.prompts == test_prompts @pytest.mark.anyio async def test_list_resources_basic() -> None: """Test basic resource listing without pagination.""" - server = Server("test") - test_resources = [ - Resource(uri=AnyUrl("file:///test1.txt"), name="Test 1"), - Resource(uri=AnyUrl("file:///test2.txt"), name="Test 2"), + Resource(uri="file:///test1.txt", name="Test 1"), + Resource(uri="file:///test2.txt", name="Test 2"), ] - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - - @server.list_resources() - async def handle_list_resources() -> list[Resource]: - return test_resources + async def handle_list_resources( + ctx: ServerRequestContext, params: PaginatedRequestParams | None + ) -> ListResourcesResult: + return ListResourcesResult(resources=test_resources) - handler = server.request_handlers[ListResourcesRequest] - request = ListResourcesRequest(method="resources/list", params=None) - result = await handler(request) - - assert isinstance(result, ServerResult) - assert isinstance(result.root, ListResourcesResult) - assert result.root.resources == test_resources + server = Server("test", on_list_resources=handle_list_resources) + async with Client(server) as client: + result = await client.list_resources() + assert result.resources == test_resources @pytest.mark.anyio async def test_list_tools_basic() -> None: """Test basic tool listing without pagination.""" - server = Server("test") - test_tools = [ Tool( name="tool1", description="First tool", - inputSchema={ + input_schema={ "type": "object", "properties": { "message": {"type": "string"}, @@ -92,7 +71,7 @@ async def test_list_tools_basic() -> None: Tool( name="tool2", description="Second tool", - inputSchema={ + input_schema={ "type": "object", "properties": { "count": {"type": "number"}, @@ -103,80 +82,53 @@ async def test_list_tools_basic() -> None: ), ] - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) + async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=test_tools) - @server.list_tools() - async def handle_list_tools() -> list[Tool]: - return test_tools - - handler = server.request_handlers[ListToolsRequest] - request = ListToolsRequest(method="tools/list", params=None) - result = await handler(request) - - assert isinstance(result, ServerResult) - assert isinstance(result.root, ListToolsResult) - assert result.root.tools == test_tools + server = Server("test", on_list_tools=handle_list_tools) + async with Client(server) as client: + result = await client.list_tools() + assert result.tools == test_tools @pytest.mark.anyio async def test_list_prompts_empty() -> None: """Test listing with empty results.""" - server = Server("test") - - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - - @server.list_prompts() - async def handle_list_prompts() -> list[Prompt]: - return [] - handler = server.request_handlers[ListPromptsRequest] - request = ListPromptsRequest(method="prompts/list", params=None) - result = await handler(request) + async def handle_list_prompts( + ctx: ServerRequestContext, params: PaginatedRequestParams | None + ) -> ListPromptsResult: + return ListPromptsResult(prompts=[]) - assert isinstance(result, ServerResult) - assert isinstance(result.root, ListPromptsResult) - assert result.root.prompts == [] + server = Server("test", on_list_prompts=handle_list_prompts) + async with Client(server) as client: + result = await client.list_prompts() + assert result.prompts == [] @pytest.mark.anyio async def test_list_resources_empty() -> None: """Test listing with empty results.""" - server = Server("test") - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) + async def handle_list_resources( + ctx: ServerRequestContext, params: PaginatedRequestParams | None + ) -> ListResourcesResult: + return ListResourcesResult(resources=[]) - @server.list_resources() - async def handle_list_resources() -> list[Resource]: - return [] - - handler = server.request_handlers[ListResourcesRequest] - request = ListResourcesRequest(method="resources/list", params=None) - result = await handler(request) - - assert isinstance(result, ServerResult) - assert isinstance(result.root, ListResourcesResult) - assert result.root.resources == [] + server = Server("test", on_list_resources=handle_list_resources) + async with Client(server) as client: + result = await client.list_resources() + assert result.resources == [] @pytest.mark.anyio async def test_list_tools_empty() -> None: """Test listing with empty results.""" - server = Server("test") - - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - - @server.list_tools() - async def handle_list_tools() -> list[Tool]: - return [] - handler = server.request_handlers[ListToolsRequest] - request = ListToolsRequest(method="tools/list", params=None) - result = await handler(request) + async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[]) - assert isinstance(result, ServerResult) - assert isinstance(result.root, ListToolsResult) - assert result.root.tools == [] + server = Server("test", on_list_tools=handle_list_tools) + async with Client(server) as client: + result = await client.list_tools() + assert result.tools == [] diff --git a/tests/server/lowlevel/test_server_pagination.py b/tests/server/lowlevel/test_server_pagination.py index 8d64dd525..a4627b316 100644 --- a/tests/server/lowlevel/test_server_pagination.py +++ b/tests/server/lowlevel/test_server_pagination.py @@ -1,111 +1,83 @@ import pytest -from mcp.server import Server +from mcp import Client +from mcp.server import Server, ServerRequestContext from mcp.types import ( - ListPromptsRequest, ListPromptsResult, - ListResourcesRequest, ListResourcesResult, - ListToolsRequest, ListToolsResult, PaginatedRequestParams, - ServerResult, ) @pytest.mark.anyio async def test_list_prompts_pagination() -> None: - server = Server("test") test_cursor = "test-cursor-123" + received_params: PaginatedRequestParams | None = None - # Track what request was received - received_request: ListPromptsRequest | None = None + async def handle_list_prompts( + ctx: ServerRequestContext, params: PaginatedRequestParams | None + ) -> ListPromptsResult: + nonlocal received_params + received_params = params + return ListPromptsResult(prompts=[], next_cursor="next") - @server.list_prompts() - async def handle_list_prompts(request: ListPromptsRequest) -> ListPromptsResult: - nonlocal received_request - received_request = request - return ListPromptsResult(prompts=[], nextCursor="next") + server = Server("test", on_list_prompts=handle_list_prompts) + async with Client(server) as client: + # No cursor provided + await client.list_prompts() + assert received_params is not None + assert received_params.cursor is None - handler = server.request_handlers[ListPromptsRequest] - - # Test: No cursor provided -> handler receives request with None params - request = ListPromptsRequest(method="prompts/list", params=None) - result = await handler(request) - assert received_request is not None - assert received_request.params is None - assert isinstance(result, ServerResult) - - # Test: Cursor provided -> handler receives request with cursor in params - request_with_cursor = ListPromptsRequest(method="prompts/list", params=PaginatedRequestParams(cursor=test_cursor)) - result2 = await handler(request_with_cursor) - assert received_request is not None - assert received_request.params is not None - assert received_request.params.cursor == test_cursor - assert isinstance(result2, ServerResult) + # Cursor provided + await client.list_prompts(cursor=test_cursor) + assert received_params is not None + assert received_params.cursor == test_cursor @pytest.mark.anyio async def test_list_resources_pagination() -> None: - server = Server("test") test_cursor = "resource-cursor-456" + received_params: PaginatedRequestParams | None = None - # Track what request was received - received_request: ListResourcesRequest | None = None - - @server.list_resources() - async def handle_list_resources(request: ListResourcesRequest) -> ListResourcesResult: - nonlocal received_request - received_request = request - return ListResourcesResult(resources=[], nextCursor="next") + async def handle_list_resources( + ctx: ServerRequestContext, params: PaginatedRequestParams | None + ) -> ListResourcesResult: + nonlocal received_params + received_params = params + return ListResourcesResult(resources=[], next_cursor="next") - handler = server.request_handlers[ListResourcesRequest] + server = Server("test", on_list_resources=handle_list_resources) + async with Client(server) as client: + # No cursor provided + await client.list_resources() + assert received_params is not None + assert received_params.cursor is None - # Test: No cursor provided -> handler receives request with None params - request = ListResourcesRequest(method="resources/list", params=None) - result = await handler(request) - assert received_request is not None - assert received_request.params is None - assert isinstance(result, ServerResult) - - # Test: Cursor provided -> handler receives request with cursor in params - request_with_cursor = ListResourcesRequest( - method="resources/list", params=PaginatedRequestParams(cursor=test_cursor) - ) - result2 = await handler(request_with_cursor) - assert received_request is not None - assert received_request.params is not None - assert received_request.params.cursor == test_cursor - assert isinstance(result2, ServerResult) + # Cursor provided + await client.list_resources(cursor=test_cursor) + assert received_params is not None + assert received_params.cursor == test_cursor @pytest.mark.anyio async def test_list_tools_pagination() -> None: - server = Server("test") test_cursor = "tools-cursor-789" - - # Track what request was received - received_request: ListToolsRequest | None = None - - @server.list_tools() - async def handle_list_tools(request: ListToolsRequest) -> ListToolsResult: - nonlocal received_request - received_request = request - return ListToolsResult(tools=[], nextCursor="next") - - handler = server.request_handlers[ListToolsRequest] - - # Test: No cursor provided -> handler receives request with None params - request = ListToolsRequest(method="tools/list", params=None) - result = await handler(request) - assert received_request is not None - assert received_request.params is None - assert isinstance(result, ServerResult) - - # Test: Cursor provided -> handler receives request with cursor in params - request_with_cursor = ListToolsRequest(method="tools/list", params=PaginatedRequestParams(cursor=test_cursor)) - result2 = await handler(request_with_cursor) - assert received_request is not None - assert received_request.params is not None - assert received_request.params.cursor == test_cursor - assert isinstance(result2, ServerResult) + received_params: PaginatedRequestParams | None = None + + async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + nonlocal received_params + received_params = params + return ListToolsResult(tools=[], next_cursor="next") + + server = Server("test", on_list_tools=handle_list_tools) + async with Client(server) as client: + # No cursor provided + await client.list_tools() + assert received_params is not None + assert received_params.cursor is None + + # Cursor provided + await client.list_tools(cursor=test_cursor) + assert received_params is not None + assert received_params.cursor == test_cursor diff --git a/tests/server/mcpserver/__init__.py b/tests/server/mcpserver/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/server/mcpserver/auth/__init__.py b/tests/server/mcpserver/auth/__init__.py new file mode 100644 index 000000000..c932e236e --- /dev/null +++ b/tests/server/mcpserver/auth/__init__.py @@ -0,0 +1 @@ +"""Tests for the MCP server auth components.""" diff --git a/tests/server/fastmcp/auth/test_auth_integration.py b/tests/server/mcpserver/auth/test_auth_integration.py similarity index 93% rename from tests/server/fastmcp/auth/test_auth_integration.py rename to tests/server/mcpserver/auth/test_auth_integration.py index 08fcabf27..602f5cc75 100644 --- a/tests/server/fastmcp/auth/test_auth_integration.py +++ b/tests/server/mcpserver/auth/test_auth_integration.py @@ -1,6 +1,4 @@ -""" -Integration tests for MCP authorization components. -""" +"""Integration tests for MCP authorization components.""" import base64 import hashlib @@ -23,7 +21,8 @@ RefreshToken, construct_redirect_uri, ) -from mcp.server.auth.routes import ClientRegistrationOptions, RevocationOptions, create_auth_routes +from mcp.server.auth.routes import create_auth_routes +from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions from mcp.shared.auth import OAuthClientInformationFull, OAuthToken @@ -174,7 +173,7 @@ async def load_access_token(self, token: str) -> AccessToken | None: async def revoke_token(self, token: AccessToken | RefreshToken) -> None: match token: - case RefreshToken(): # pragma: no cover + case RefreshToken(): # pragma: lax no cover # Remove the refresh token del self.refresh_tokens[token.token] @@ -339,9 +338,59 @@ async def test_token_validation_error(self, test_client: httpx.AsyncClient): }, ) error_response = response.json() - assert error_response["error"] == "unauthorized_client" + # Per RFC 6749 Section 5.2, authentication failures (missing client_id) + # must return "invalid_client", not "unauthorized_client" + assert error_response["error"] == "invalid_client" assert "error_description" in error_response # Contains error message + @pytest.mark.anyio + async def test_token_invalid_client_secret_returns_invalid_client( + self, + test_client: httpx.AsyncClient, + registered_client: dict[str, Any], + pkce_challenge: dict[str, str], + mock_oauth_provider: MockOAuthProvider, + ): + """Test token endpoint returns 'invalid_client' for wrong client_secret per RFC 6749. + + RFC 6749 Section 5.2 defines: + - invalid_client: Client authentication failed (wrong credentials, unknown client) + - unauthorized_client: Authenticated client not authorized for grant type + + When client_secret is wrong, this is an authentication failure, so the + error code MUST be 'invalid_client'. + """ + # Create an auth code for the registered client + auth_code = f"code_{int(time.time())}" + mock_oauth_provider.auth_codes[auth_code] = AuthorizationCode( + code=auth_code, + client_id=registered_client["client_id"], + code_challenge=pkce_challenge["code_challenge"], + redirect_uri=AnyUrl("https://client.example.com/callback"), + redirect_uri_provided_explicitly=True, + scopes=["read", "write"], + expires_at=time.time() + 600, + ) + + # Try to exchange the auth code with a WRONG client_secret + response = await test_client.post( + "/token", + data={ + "grant_type": "authorization_code", + "client_id": registered_client["client_id"], + "client_secret": "wrong_secret_that_does_not_match", + "code": auth_code, + "code_verifier": pkce_challenge["code_verifier"], + "redirect_uri": "https://client.example.com/callback", + }, + ) + + assert response.status_code == 401 + error_response = response.json() + # RFC 6749 Section 5.2: authentication failures MUST return "invalid_client" + assert error_response["error"] == "invalid_client" + assert "Invalid client_secret" in error_response["error_description"] + @pytest.mark.anyio async def test_token_invalid_auth_code( self, @@ -889,19 +938,35 @@ async def test_client_registration_default_scopes( assert registered_client.scope == "read write" @pytest.mark.anyio - async def test_client_registration_invalid_grant_type(self, test_client: httpx.AsyncClient): + async def test_client_registration_with_authorization_code_only(self, test_client: httpx.AsyncClient): + """Test that registration succeeds with only authorization_code (refresh_token is optional per RFC 7591).""" client_metadata = { "redirect_uris": ["https://client.example.com/callback"], "client_name": "Test Client", "grant_types": ["authorization_code"], } + response = await test_client.post("/register", json=client_metadata) + assert response.status_code == 201 + client_info = response.json() + assert "client_id" in client_info + assert client_info["grant_types"] == ["authorization_code"] + + @pytest.mark.anyio + async def test_client_registration_missing_authorization_code(self, test_client: httpx.AsyncClient): + """Test that registration fails when authorization_code grant type is missing.""" + client_metadata = { + "redirect_uris": ["https://client.example.com/callback"], + "client_name": "Test Client", + "grant_types": ["refresh_token"], + } + response = await test_client.post("/register", json=client_metadata) assert response.status_code == 400 error_data = response.json() assert "error" in error_data assert error_data["error"] == "invalid_client_metadata" - assert error_data["error_description"] == "grant_types must be authorization_code and refresh_token" + assert error_data["error_description"] == "grant_types must include 'authorization_code'" @pytest.mark.anyio async def test_client_registration_with_additional_grant_type(self, test_client: httpx.AsyncClient): @@ -1070,7 +1135,8 @@ async def test_wrong_auth_method_without_valid_credentials_fails( ) assert response.status_code == 401 error_response = response.json() - assert error_response["error"] == "unauthorized_client" + # RFC 6749: authentication failures return "invalid_client" + assert error_response["error"] == "invalid_client" assert "Client secret is required" in error_response["error_description"] @pytest.mark.anyio @@ -1114,7 +1180,8 @@ async def test_basic_auth_without_header_fails( ) assert response.status_code == 401 error_response = response.json() - assert error_response["error"] == "unauthorized_client" + # RFC 6749: authentication failures return "invalid_client" + assert error_response["error"] == "invalid_client" assert "Missing or invalid Basic authentication" in error_response["error_description"] @pytest.mark.anyio @@ -1158,7 +1225,8 @@ async def test_basic_auth_invalid_base64_fails( ) assert response.status_code == 401 error_response = response.json() - assert error_response["error"] == "unauthorized_client" + # RFC 6749: authentication failures return "invalid_client" + assert error_response["error"] == "invalid_client" assert "Invalid Basic authentication header" in error_response["error_description"] @pytest.mark.anyio @@ -1189,8 +1257,6 @@ async def test_basic_auth_no_colon_fails( ) # Send base64 without colon (invalid format) - import base64 - invalid_creds = base64.b64encode(b"no-colon-here").decode() response = await test_client.post( "/token", @@ -1205,7 +1271,8 @@ async def test_basic_auth_no_colon_fails( ) assert response.status_code == 401 error_response = response.json() - assert error_response["error"] == "unauthorized_client" + # RFC 6749: authentication failures return "invalid_client" + assert error_response["error"] == "invalid_client" assert "Invalid Basic authentication header" in error_response["error_description"] @pytest.mark.anyio @@ -1236,8 +1303,6 @@ async def test_basic_auth_client_id_mismatch_fails( ) # Send different client_id in Basic auth header - import base64 - wrong_creds = base64.b64encode(f"wrong-client-id:{client_info['client_secret']}".encode()).decode() response = await test_client.post( "/token", @@ -1252,7 +1317,8 @@ async def test_basic_auth_client_id_mismatch_fails( ) assert response.status_code == 401 error_response = response.json() - assert error_response["error"] == "unauthorized_client" + # RFC 6749: authentication failures return "invalid_client" + assert error_response["error"] == "invalid_client" assert "Client ID mismatch" in error_response["error_description"] @pytest.mark.anyio diff --git a/tests/server/mcpserver/prompts/__init__.py b/tests/server/mcpserver/prompts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/server/fastmcp/prompts/test_base.py b/tests/server/mcpserver/prompts/test_base.py similarity index 88% rename from tests/server/fastmcp/prompts/test_base.py rename to tests/server/mcpserver/prompts/test_base.py index 488bd5002..553e47363 100644 --- a/tests/server/fastmcp/prompts/test_base.py +++ b/tests/server/mcpserver/prompts/test_base.py @@ -1,10 +1,9 @@ from typing import Any import pytest -from pydantic import FileUrl -from mcp.server.fastmcp.prompts.base import AssistantMessage, Message, Prompt, TextContent, UserMessage -from mcp.types import EmbeddedResource, TextResourceContents +from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, Prompt, UserMessage +from mcp.types import EmbeddedResource, TextContent, TextResourceContents class TestRenderPrompt: @@ -95,9 +94,9 @@ async def fn() -> UserMessage: content=EmbeddedResource( type="resource", resource=TextResourceContents( - uri=FileUrl("file://file.txt"), + uri="file://file.txt", text="File contents", - mimeType="text/plain", + mime_type="text/plain", ), ) ) @@ -108,9 +107,9 @@ async def fn() -> UserMessage: content=EmbeddedResource( type="resource", resource=TextResourceContents( - uri=FileUrl("file://file.txt"), + uri="file://file.txt", text="File contents", - mimeType="text/plain", + mime_type="text/plain", ), ) ) @@ -127,9 +126,9 @@ async def fn() -> list[Message]: content=EmbeddedResource( type="resource", resource=TextResourceContents( - uri=FileUrl("file://file.txt"), + uri="file://file.txt", text="File contents", - mimeType="text/plain", + mime_type="text/plain", ), ) ), @@ -143,9 +142,9 @@ async def fn() -> list[Message]: content=EmbeddedResource( type="resource", resource=TextResourceContents( - uri=FileUrl("file://file.txt"), + uri="file://file.txt", text="File contents", - mimeType="text/plain", + mime_type="text/plain", ), ) ), @@ -162,7 +161,7 @@ async def fn() -> dict[str, Any]: "content": { "type": "resource", "resource": { - "uri": FileUrl("file://file.txt"), + "uri": "file://file.txt", "text": "File contents", "mimeType": "text/plain", }, @@ -175,9 +174,9 @@ async def fn() -> dict[str, Any]: content=EmbeddedResource( type="resource", resource=TextResourceContents( - uri=FileUrl("file://file.txt"), + uri="file://file.txt", text="File contents", - mimeType="text/plain", + mime_type="text/plain", ), ) ) diff --git a/tests/server/fastmcp/prompts/test_manager.py b/tests/server/mcpserver/prompts/test_manager.py similarity index 95% rename from tests/server/fastmcp/prompts/test_manager.py rename to tests/server/mcpserver/prompts/test_manager.py index 950ffddd1..02f91c680 100644 --- a/tests/server/fastmcp/prompts/test_manager.py +++ b/tests/server/mcpserver/prompts/test_manager.py @@ -1,7 +1,8 @@ import pytest -from mcp.server.fastmcp.prompts.base import Prompt, TextContent, UserMessage -from mcp.server.fastmcp.prompts.manager import PromptManager +from mcp.server.mcpserver.prompts.base import Prompt, UserMessage +from mcp.server.mcpserver.prompts.manager import PromptManager +from mcp.types import TextContent class TestPromptManager: diff --git a/tests/server/mcpserver/resources/__init__.py b/tests/server/mcpserver/resources/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/server/fastmcp/resources/test_file_resources.py b/tests/server/mcpserver/resources/test_file_resources.py similarity index 87% rename from tests/server/fastmcp/resources/test_file_resources.py rename to tests/server/mcpserver/resources/test_file_resources.py index c82cf85c5..94885113a 100644 --- a/tests/server/fastmcp/resources/test_file_resources.py +++ b/tests/server/mcpserver/resources/test_file_resources.py @@ -3,9 +3,8 @@ from tempfile import NamedTemporaryFile import pytest -from pydantic import FileUrl -from mcp.server.fastmcp.resources import FileResource +from mcp.server.mcpserver.resources import FileResource @pytest.fixture @@ -19,9 +18,9 @@ def temp_file(): f.write(content) path = Path(f.name).resolve() yield path - try: # pragma: no cover + try: # pragma: lax no cover path.unlink() - except FileNotFoundError: # pragma: no cover + except FileNotFoundError: # pragma: lax no cover pass # File was already deleted by the test @@ -31,7 +30,7 @@ class TestFileResource: def test_file_resource_creation(self, temp_file: Path): """Test creating a FileResource.""" resource = FileResource( - uri=FileUrl(temp_file.as_uri()), + uri=temp_file.as_uri(), name="test", description="test file", path=temp_file, @@ -46,7 +45,7 @@ def test_file_resource_creation(self, temp_file: Path): def test_file_resource_str_path_conversion(self, temp_file: Path): """Test FileResource handles string paths.""" resource = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=f"file://{temp_file}", name="test", path=Path(str(temp_file)), ) @@ -57,7 +56,7 @@ def test_file_resource_str_path_conversion(self, temp_file: Path): async def test_read_text_file(self, temp_file: Path): """Test reading a text file.""" resource = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=f"file://{temp_file}", name="test", path=temp_file, ) @@ -69,7 +68,7 @@ async def test_read_text_file(self, temp_file: Path): async def test_read_binary_file(self, temp_file: Path): """Test reading a file as binary.""" resource = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=f"file://{temp_file}", name="test", path=temp_file, is_binary=True, @@ -82,7 +81,7 @@ def test_relative_path_error(self): """Test error on relative path.""" with pytest.raises(ValueError, match="Path must be absolute"): FileResource( - uri=FileUrl("file:///test.txt"), + uri="file:///test.txt", name="test", path=Path("test.txt"), ) @@ -93,7 +92,7 @@ async def test_missing_file_error(self, temp_file: Path): # Create path to non-existent file missing = temp_file.parent / "missing.txt" resource = FileResource( - uri=FileUrl("file:///missing.txt"), + uri="file:///missing.txt", name="test", path=missing, ) @@ -102,12 +101,12 @@ async def test_missing_file_error(self, temp_file: Path): @pytest.mark.skipif(os.name == "nt", reason="File permissions behave differently on Windows") @pytest.mark.anyio - async def test_permission_error(self, temp_file: Path): # pragma: no cover + async def test_permission_error(self, temp_file: Path): # pragma: lax no cover """Test reading a file without permissions.""" temp_file.chmod(0o000) # Remove all permissions try: resource = FileResource( - uri=FileUrl(temp_file.as_uri()), + uri=temp_file.as_uri(), name="test", path=temp_file, ) diff --git a/tests/server/fastmcp/resources/test_function_resources.py b/tests/server/mcpserver/resources/test_function_resources.py similarity index 73% rename from tests/server/fastmcp/resources/test_function_resources.py rename to tests/server/mcpserver/resources/test_function_resources.py index fccada475..5f5c216ed 100644 --- a/tests/server/fastmcp/resources/test_function_resources.py +++ b/tests/server/mcpserver/resources/test_function_resources.py @@ -1,7 +1,7 @@ import pytest -from pydantic import AnyUrl, BaseModel +from pydantic import BaseModel -from mcp.server.fastmcp.resources import FunctionResource +from mcp.server.mcpserver.resources import FunctionResource class TestFunctionResource: @@ -14,7 +14,7 @@ def my_func() -> str: # pragma: no cover return "test content" resource = FunctionResource( - uri=AnyUrl("fn://test"), + uri="fn://test", name="test", description="test function", fn=my_func, @@ -33,7 +33,7 @@ def get_data() -> str: return "Hello, world!" resource = FunctionResource( - uri=AnyUrl("function://test"), + uri="function://test", name="test", fn=get_data, ) @@ -49,7 +49,7 @@ def get_data() -> bytes: return b"Hello, world!" resource = FunctionResource( - uri=AnyUrl("function://test"), + uri="function://test", name="test", fn=get_data, ) @@ -64,7 +64,7 @@ def get_data() -> dict[str, str]: return {"key": "value"} resource = FunctionResource( - uri=AnyUrl("function://test"), + uri="function://test", name="test", fn=get_data, ) @@ -80,7 +80,7 @@ def failing_func() -> str: raise ValueError("Test error") resource = FunctionResource( - uri=AnyUrl("function://test"), + uri="function://test", name="test", fn=failing_func, ) @@ -95,7 +95,7 @@ class MyModel(BaseModel): name: str resource = FunctionResource( - uri=AnyUrl("function://test"), + uri="function://test", name="test", fn=lambda: MyModel(name="test"), ) @@ -114,7 +114,7 @@ def get_data() -> CustomData: return CustomData() resource = FunctionResource( - uri=AnyUrl("function://test"), + uri="function://test", name="test", fn=get_data, ) @@ -129,7 +129,7 @@ async def get_data() -> str: return "Hello, world!" resource = FunctionResource( - uri=AnyUrl("function://test"), + uri="function://test", name="test", fn=get_data, ) @@ -154,4 +154,39 @@ async def get_data() -> str: # pragma: no cover assert resource.description == "get_data returns a string" assert resource.mime_type == "text/plain" assert resource.name == "test" - assert resource.uri == AnyUrl("function://test") + assert resource.uri == "function://test" + + +class TestFunctionResourceMetadata: + def test_from_function_with_metadata(self): + # from_function() accepts meta dict and stores it on the resource for static resources + + def get_data() -> str: # pragma: no cover + return "test data" + + metadata = {"cache_ttl": 300, "tags": ["data", "readonly"]} + + resource = FunctionResource.from_function( + fn=get_data, + uri="resource://data", + meta=metadata, + ) + + assert resource.meta is not None + assert resource.meta == metadata + assert resource.meta["cache_ttl"] == 300 + assert "data" in resource.meta["tags"] + assert "readonly" in resource.meta["tags"] + + def test_from_function_without_metadata(self): + # meta parameter is optional and defaults to None for backward compatibility + + def get_data() -> str: # pragma: no cover + return "test data" + + resource = FunctionResource.from_function( + fn=get_data, + uri="resource://data", + ) + + assert resource.meta is None diff --git a/tests/server/fastmcp/resources/test_resource_manager.py b/tests/server/mcpserver/resources/test_resource_manager.py similarity index 71% rename from tests/server/fastmcp/resources/test_resource_manager.py rename to tests/server/mcpserver/resources/test_resource_manager.py index a0c06be86..eb9b355aa 100644 --- a/tests/server/fastmcp/resources/test_resource_manager.py +++ b/tests/server/mcpserver/resources/test_resource_manager.py @@ -2,9 +2,9 @@ from tempfile import NamedTemporaryFile import pytest -from pydantic import AnyUrl, FileUrl +from pydantic import AnyUrl -from mcp.server.fastmcp.resources import FileResource, FunctionResource, ResourceManager, ResourceTemplate +from mcp.server.mcpserver.resources import FileResource, FunctionResource, ResourceManager, ResourceTemplate @pytest.fixture @@ -18,9 +18,9 @@ def temp_file(): f.write(content) path = Path(f.name).resolve() yield path - try: # pragma: no cover + try: # pragma: lax no cover path.unlink() - except FileNotFoundError: # pragma: no cover + except FileNotFoundError: # pragma: lax no cover pass # File was already deleted by the test @@ -31,7 +31,7 @@ def test_add_resource(self, temp_file: Path): """Test adding a resource.""" manager = ResourceManager() resource = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=f"file://{temp_file}", name="test", path=temp_file, ) @@ -43,7 +43,7 @@ def test_add_duplicate_resource(self, temp_file: Path): """Test adding the same resource twice.""" manager = ResourceManager() resource = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=f"file://{temp_file}", name="test", path=temp_file, ) @@ -56,7 +56,7 @@ def test_warn_on_duplicate_resources(self, temp_file: Path, caplog: pytest.LogCa """Test warning on duplicate resources.""" manager = ResourceManager() resource = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=f"file://{temp_file}", name="test", path=temp_file, ) @@ -68,7 +68,7 @@ def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog: pyte """Test disabling warning on duplicate resources.""" manager = ResourceManager(warn_on_duplicate_resources=False) resource = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=f"file://{temp_file}", name="test", path=temp_file, ) @@ -81,7 +81,7 @@ async def test_get_resource(self, temp_file: Path): """Test getting a resource by URI.""" manager = ResourceManager() resource = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=f"file://{temp_file}", name="test", path=temp_file, ) @@ -120,12 +120,12 @@ def test_list_resources(self, temp_file: Path): """Test listing all resources.""" manager = ResourceManager() resource1 = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=f"file://{temp_file}", name="test1", path=temp_file, ) resource2 = FileResource( - uri=FileUrl(f"file://{temp_file}2"), + uri=f"file://{temp_file}2", name="test2", path=temp_file, ) @@ -134,3 +134,43 @@ def test_list_resources(self, temp_file: Path): resources = manager.list_resources() assert len(resources) == 2 assert resources == [resource1, resource2] + + +class TestResourceManagerMetadata: + """Test ResourceManager Metadata""" + + def test_add_template_with_metadata(self): + """Test that ResourceManager.add_template() accepts and passes meta parameter.""" + + manager = ResourceManager() + + def get_item(id: str) -> str: # pragma: no cover + return f"Item {id}" + + metadata = {"source": "database", "cached": True} + + template = manager.add_template( + fn=get_item, + uri_template="resource://items/{id}", + meta=metadata, + ) + + assert template.meta is not None + assert template.meta == metadata + assert template.meta["source"] == "database" + assert template.meta["cached"] is True + + def test_add_template_without_metadata(self): + """Test that ResourceManager.add_template() works without meta parameter.""" + + manager = ResourceManager() + + def get_item(id: str) -> str: # pragma: no cover + return f"Item {id}" + + template = manager.add_template( + fn=get_item, + uri_template="resource://items/{id}", + ) + + assert template.meta is None diff --git a/tests/server/fastmcp/resources/test_resource_template.py b/tests/server/mcpserver/resources/test_resource_template.py similarity index 79% rename from tests/server/fastmcp/resources/test_resource_template.py rename to tests/server/mcpserver/resources/test_resource_template.py index c910f8fa8..08f2033bf 100644 --- a/tests/server/fastmcp/resources/test_resource_template.py +++ b/tests/server/mcpserver/resources/test_resource_template.py @@ -4,8 +4,8 @@ import pytest from pydantic import BaseModel -from mcp.server.fastmcp import FastMCP -from mcp.server.fastmcp.resources import FunctionResource, ResourceTemplate +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.resources import FunctionResource, ResourceTemplate from mcp.types import Annotations @@ -15,7 +15,7 @@ class TestResourceTemplate: def test_template_creation(self): """Test creating a template from a function.""" - def my_func(key: str, value: int) -> dict[str, Any]: # pragma: no cover + def my_func(key: str, value: int) -> dict[str, Any]: return {"key": key, "value": value} template = ResourceTemplate.from_function( @@ -219,10 +219,10 @@ def get_user_data(user_id: str) -> str: # pragma: no cover assert template.annotations is None @pytest.mark.anyio - async def test_template_annotations_in_fastmcp(self): - """Test template annotations via FastMCP decorator.""" + async def test_template_annotations_in_mcpserver(self): + """Test template annotations via an MCPServer decorator.""" - mcp = FastMCP() + mcp = MCPServer() @mcp.resource("resource://dynamic/{id}", annotations=Annotations(audience=["user"], priority=0.7)) def get_dynamic(id: str) -> str: # pragma: no cover @@ -239,7 +239,7 @@ def get_dynamic(id: str) -> str: # pragma: no cover async def test_template_created_resources_inherit_annotations(self): """Test that resources created from templates inherit annotations.""" - def get_item(item_id: str) -> str: # pragma: no cover + def get_item(item_id: str) -> str: return f"Item {item_id}" annotations = Annotations(priority=0.6) @@ -258,3 +258,50 @@ def get_item(item_id: str) -> str: # pragma: no cover # Verify the resource works correctly content = await resource.read() assert content == "Item 123" + + +class TestResourceTemplateMetadata: + """Test ResourceTemplate meta handling.""" + + def test_template_from_function_with_metadata(self): + """Test that ResourceTemplate.from_function() accepts and stores meta parameter.""" + + def get_user(user_id: str) -> str: # pragma: no cover + return f"User {user_id}" + + metadata = {"requires_auth": True, "rate_limit": 100} + + template = ResourceTemplate.from_function( + fn=get_user, + uri_template="resource://users/{user_id}", + meta=metadata, + ) + + assert template.meta is not None + assert template.meta == metadata + assert template.meta["requires_auth"] is True + assert template.meta["rate_limit"] == 100 + + @pytest.mark.anyio + async def test_template_created_resources_inherit_metadata(self): + """Test that resources created from templates inherit meta from template.""" + + def get_item(item_id: str) -> str: + return f"Item {item_id}" + + metadata = {"category": "inventory", "cacheable": True} + + template = ResourceTemplate.from_function( + fn=get_item, + uri_template="resource://items/{item_id}", + meta=metadata, + ) + + # Create a resource from the template + resource = await template.create_resource("resource://items/123", {"item_id": "123"}) + + # The resource should inherit the template's metadata + assert resource.meta is not None + assert resource.meta == metadata + assert resource.meta["category"] == "inventory" + assert resource.meta["cacheable"] is True diff --git a/tests/server/fastmcp/resources/test_resources.py b/tests/server/mcpserver/resources/test_resources.py similarity index 69% rename from tests/server/fastmcp/resources/test_resources.py rename to tests/server/mcpserver/resources/test_resources.py index 32fc23b17..93dc438d5 100644 --- a/tests/server/fastmcp/resources/test_resources.py +++ b/tests/server/mcpserver/resources/test_resources.py @@ -1,43 +1,42 @@ import pytest -from pydantic import AnyUrl -from mcp.server.fastmcp import FastMCP -from mcp.server.fastmcp.resources import FunctionResource, Resource +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.resources import FunctionResource, Resource from mcp.types import Annotations class TestResourceValidation: """Test base Resource validation.""" - def test_resource_uri_validation(self): - """Test URI validation.""" + def test_resource_uri_accepts_any_string(self): + """Test that URI field accepts any string per MCP spec.""" def dummy_func() -> str: # pragma: no cover return "data" # Valid URI resource = FunctionResource( - uri=AnyUrl("http://example.com/data"), + uri="http://example.com/data", name="test", fn=dummy_func, ) - assert str(resource.uri) == "http://example.com/data" + assert resource.uri == "http://example.com/data" - # Missing protocol - with pytest.raises(ValueError, match="Input should be a valid URL"): - FunctionResource( - uri=AnyUrl("invalid"), - name="test", - fn=dummy_func, - ) + # Relative path - now accepted per MCP spec + resource = FunctionResource( + uri="users/me", + name="test", + fn=dummy_func, + ) + assert resource.uri == "users/me" - # Missing host - with pytest.raises(ValueError, match="Input should be a valid URL"): - FunctionResource( - uri=AnyUrl("http://"), - name="test", - fn=dummy_func, - ) + # Custom scheme + resource = FunctionResource( + uri="custom://resource", + name="test", + fn=dummy_func, + ) + assert resource.uri == "custom://resource" def test_resource_name_from_uri(self): """Test name is extracted from URI if not provided.""" @@ -46,7 +45,7 @@ def dummy_func() -> str: # pragma: no cover return "data" resource = FunctionResource( - uri=AnyUrl("resource://my-resource"), + uri="resource://my-resource", fn=dummy_func, ) assert resource.name == "resource://my-resource" @@ -65,7 +64,7 @@ def dummy_func() -> str: # pragma: no cover # Explicit name takes precedence over URI resource = FunctionResource( - uri=AnyUrl("resource://uri-name"), + uri="resource://uri-name", name="explicit-name", fn=dummy_func, ) @@ -79,14 +78,14 @@ def dummy_func() -> str: # pragma: no cover # Default mime type resource = FunctionResource( - uri=AnyUrl("resource://test"), + uri="resource://test", fn=dummy_func, ) assert resource.mime_type == "text/plain" # Custom mime type resource = FunctionResource( - uri=AnyUrl("resource://test"), + uri="resource://test", fn=dummy_func, mime_type="application/json", ) @@ -100,7 +99,7 @@ class ConcreteResource(Resource): pass with pytest.raises(TypeError, match="abstract method"): - ConcreteResource(uri=AnyUrl("test://test"), name="test") # type: ignore + ConcreteResource(uri="test://test", name="test") # type: ignore class TestResourceAnnotations: @@ -131,10 +130,10 @@ def get_data() -> str: # pragma: no cover assert resource.annotations is None @pytest.mark.anyio - async def test_resource_annotations_in_fastmcp(self): - """Test resource annotations via FastMCP decorator.""" + async def test_resource_annotations_in_mcpserver(self): + """Test resource annotations via MCPServer decorator.""" - mcp = FastMCP() + mcp = MCPServer() @mcp.resource("resource://annotated", annotations=Annotations(audience=["assistant"], priority=0.5)) def get_annotated() -> str: # pragma: no cover @@ -151,7 +150,7 @@ def get_annotated() -> str: # pragma: no cover async def test_resource_annotations_with_both_audiences(self): """Test resource with both user and assistant audience.""" - mcp = FastMCP() + mcp = MCPServer() @mcp.resource("resource://both", annotations=Annotations(audience=["user", "assistant"], priority=1.0)) def get_both() -> str: # pragma: no cover @@ -193,3 +192,41 @@ def test_audience_validation(self): # Invalid roles should raise validation error with pytest.raises(Exception): # Pydantic validation error Annotations(audience=["invalid_role"]) # type: ignore + + +class TestResourceMetadata: + """Test metadata field on base Resource class.""" + + def test_resource_with_metadata(self): + """Test that Resource base class accepts meta parameter.""" + + def dummy_func() -> str: # pragma: no cover + return "data" + + metadata = {"version": "1.0", "category": "test"} + + resource = FunctionResource( + uri="resource://test", + name="test", + fn=dummy_func, + meta=metadata, + ) + + assert resource.meta is not None + assert resource.meta == metadata + assert resource.meta["version"] == "1.0" + assert resource.meta["category"] == "test" + + def test_resource_without_metadata(self): + """Test that meta field defaults to None.""" + + def dummy_func() -> str: # pragma: no cover + return "data" + + resource = FunctionResource( + uri="resource://test", + name="test", + fn=dummy_func, + ) + + assert resource.meta is None diff --git a/tests/server/mcpserver/servers/__init__.py b/tests/server/mcpserver/servers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/server/fastmcp/servers/test_file_server.py b/tests/server/mcpserver/servers/test_file_server.py similarity index 86% rename from tests/server/fastmcp/servers/test_file_server.py rename to tests/server/mcpserver/servers/test_file_server.py index b8c9ad3d6..9c3fe265c 100644 --- a/tests/server/fastmcp/servers/test_file_server.py +++ b/tests/server/mcpserver/servers/test_file_server.py @@ -3,7 +3,7 @@ import pytest -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer @pytest.fixture() @@ -20,14 +20,14 @@ def test_dir(tmp_path_factory: pytest.TempPathFactory) -> Path: @pytest.fixture -def mcp() -> FastMCP: - mcp = FastMCP() +def mcp() -> MCPServer: + mcp = MCPServer() return mcp @pytest.fixture(autouse=True) -def resources(mcp: FastMCP, test_dir: Path) -> FastMCP: +def resources(mcp: MCPServer, test_dir: Path) -> MCPServer: @mcp.resource("dir://test_dir") def list_test_dir() -> list[str]: """List the files in the test directory""" @@ -61,7 +61,7 @@ def read_config_json() -> str: @pytest.fixture(autouse=True) -def tools(mcp: FastMCP, test_dir: Path) -> FastMCP: +def tools(mcp: MCPServer, test_dir: Path) -> MCPServer: @mcp.tool() def delete_file(path: str) -> bool: # ensure path is in test_dir @@ -74,7 +74,7 @@ def delete_file(path: str) -> bool: @pytest.mark.anyio -async def test_list_resources(mcp: FastMCP): +async def test_list_resources(mcp: MCPServer): resources = await mcp.list_resources() assert len(resources) == 4 @@ -87,7 +87,7 @@ async def test_list_resources(mcp: FastMCP): @pytest.mark.anyio -async def test_read_resource_dir(mcp: FastMCP): +async def test_read_resource_dir(mcp: MCPServer): res_iter = await mcp.read_resource("dir://test_dir") res_list = list(res_iter) assert len(res_list) == 1 @@ -104,7 +104,7 @@ async def test_read_resource_dir(mcp: FastMCP): @pytest.mark.anyio -async def test_read_resource_file(mcp: FastMCP): +async def test_read_resource_file(mcp: MCPServer): res_iter = await mcp.read_resource("file://test_dir/example.py") res_list = list(res_iter) assert len(res_list) == 1 @@ -113,13 +113,13 @@ async def test_read_resource_file(mcp: FastMCP): @pytest.mark.anyio -async def test_delete_file(mcp: FastMCP, test_dir: Path): +async def test_delete_file(mcp: MCPServer, test_dir: Path): await mcp.call_tool("delete_file", arguments={"path": str(test_dir / "example.py")}) assert not (test_dir / "example.py").exists() @pytest.mark.anyio -async def test_delete_file_and_check_resources(mcp: FastMCP, test_dir: Path): +async def test_delete_file_and_check_resources(mcp: MCPServer, test_dir: Path): await mcp.call_tool("delete_file", arguments={"path": str(test_dir / "example.py")}) res_iter = await mcp.read_resource("file://test_dir/example.py") res_list = list(res_iter) diff --git a/tests/server/fastmcp/test_elicitation.py b/tests/server/mcpserver/test_elicitation.py similarity index 88% rename from tests/server/fastmcp/test_elicitation.py rename to tests/server/mcpserver/test_elicitation.py index 597b29178..6cf49fbd7 100644 --- a/tests/server/fastmcp/test_elicitation.py +++ b/tests/server/mcpserver/test_elicitation.py @@ -1,18 +1,15 @@ -""" -Test the elicitation feature using stdio transport. -""" +"""Test the elicitation feature using stdio transport.""" from typing import Any import pytest from pydantic import BaseModel, Field -from mcp import types +from mcp import Client, types from mcp.client.session import ClientSession, ElicitationFnT -from mcp.server.fastmcp import Context, FastMCP +from mcp.server.mcpserver import Context, MCPServer from mcp.server.session import ServerSession -from mcp.shared.context import RequestContext -from mcp.shared.memory import create_connected_server_and_client_session +from mcp.shared._context import RequestContext from mcp.types import ElicitRequestParams, ElicitResult, TextContent @@ -21,7 +18,7 @@ class AnswerSchema(BaseModel): answer: str = Field(description="The user's answer to the question") -def create_ask_user_tool(mcp: FastMCP): +def create_ask_user_tool(mcp: MCPServer): """Create a standard ask_user tool that handles all elicitation responses.""" @mcp.tool(description="A tool that uses elicitation") @@ -39,7 +36,7 @@ async def ask_user(prompt: str, ctx: Context[ServerSession, None]) -> str: async def call_tool_and_assert( - mcp: FastMCP, + mcp: MCPServer, elicitation_callback: ElicitationFnT, tool_name: str, args: dict[str, Any], @@ -47,12 +44,8 @@ async def call_tool_and_assert( text_contains: list[str] | None = None, ): """Helper to create session, call tool, and assert result.""" - async with create_connected_server_and_client_session( - mcp._mcp_server, elicitation_callback=elicitation_callback - ) as client_session: - await client_session.initialize() - - result = await client_session.call_tool(tool_name, args) + async with Client(mcp, elicitation_callback=elicitation_callback) as client: + result = await client.call_tool(tool_name, args) assert len(result.content) == 1 assert isinstance(result.content[0], TextContent) @@ -68,16 +61,14 @@ async def call_tool_and_assert( @pytest.mark.anyio async def test_stdio_elicitation(): """Test the elicitation feature using stdio transport.""" - mcp = FastMCP(name="StdioElicitationServer") + mcp = MCPServer(name="StdioElicitationServer") create_ask_user_tool(mcp) # Create a custom handler for elicitation requests - async def elicitation_callback( - context: RequestContext[ClientSession, None], params: ElicitRequestParams - ): # pragma: no cover + async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams): if params.message == "Tool wants to ask: What is your name?": return ElicitResult(action="accept", content={"answer": "Test User"}) - else: + else: # pragma: no cover raise ValueError(f"Unexpected elicitation message: {params.message}") await call_tool_and_assert( @@ -88,10 +79,10 @@ async def elicitation_callback( @pytest.mark.anyio async def test_stdio_elicitation_decline(): """Test elicitation with user declining.""" - mcp = FastMCP(name="StdioElicitationDeclineServer") + mcp = MCPServer(name="StdioElicitationDeclineServer") create_ask_user_tool(mcp) - async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams): return ElicitResult(action="decline") await call_tool_and_assert( @@ -102,14 +93,14 @@ async def elicitation_callback(context: RequestContext[ClientSession, None], par @pytest.mark.anyio async def test_elicitation_schema_validation(): """Test that elicitation schemas must only contain primitive types.""" - mcp = FastMCP(name="ValidationTestServer") + mcp = MCPServer(name="ValidationTestServer") def create_validation_tool(name: str, schema_class: type[BaseModel]): @mcp.tool(name=name, description=f"Tool testing {name}") - async def tool(ctx: Context[ServerSession, None]) -> str: # pragma: no cover + async def tool(ctx: Context[ServerSession, None]) -> str: try: await ctx.elicit(message="This should fail validation", schema=schema_class) - return "Should not reach here" + return "Should not reach here" # pragma: no cover except TypeError as e: return f"Validation failed as expected: {str(e)}" @@ -130,18 +121,14 @@ class InvalidNestedSchema(BaseModel): # Dummy callback (won't be called due to validation failure) async def elicitation_callback( - context: RequestContext[ClientSession, None], params: ElicitRequestParams + context: RequestContext[ClientSession], params: ElicitRequestParams ): # pragma: no cover return ElicitResult(action="accept", content={}) - async with create_connected_server_and_client_session( - mcp._mcp_server, elicitation_callback=elicitation_callback - ) as client_session: - await client_session.initialize() - + async with Client(mcp, elicitation_callback=elicitation_callback) as client: # Test both invalid schemas for tool_name, field_name in [("invalid_list", "numbers"), ("nested_model", "nested")]: - result = await client_session.call_tool(tool_name, {}) + result = await client.call_tool(tool_name, {}) assert len(result.content) == 1 assert isinstance(result.content[0], TextContent) assert "Validation failed as expected" in result.content[0].text @@ -151,7 +138,7 @@ async def elicitation_callback( @pytest.mark.anyio async def test_elicitation_with_optional_fields(): """Test that Optional fields work correctly in elicitation schemas.""" - mcp = FastMCP(name="OptionalFieldServer") + mcp = MCPServer(name="OptionalFieldServer") class OptionalSchema(BaseModel): required_name: str = Field(description="Your name (required)") @@ -190,7 +177,7 @@ async def optional_tool(ctx: Context[ServerSession, None]) -> str: for content, expected in test_cases: - async def callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + async def callback(context: RequestContext[ClientSession], params: ElicitRequestParams): return ElicitResult(action="accept", content=content) await call_tool_and_assert(mcp, callback, "optional_tool", {}, expected) @@ -201,15 +188,15 @@ class InvalidOptionalSchema(BaseModel): optional_list: list[int] | None = Field(default=None, description="Invalid optional list") @mcp.tool(description="Tool with invalid optional field") - async def invalid_optional_tool(ctx: Context[ServerSession, None]) -> str: # pragma: no cover + async def invalid_optional_tool(ctx: Context[ServerSession, None]) -> str: try: await ctx.elicit(message="This should fail", schema=InvalidOptionalSchema) - return "Should not reach here" + return "Should not reach here" # pragma: no cover except TypeError as e: return f"Validation failed: {str(e)}" async def elicitation_callback( - context: RequestContext[ClientSession, None], params: ElicitRequestParams + context: RequestContext[ClientSession], params: ElicitRequestParams ): # pragma: no cover return ElicitResult(action="accept", content={}) @@ -233,7 +220,7 @@ async def valid_multiselect_tool(ctx: Context[ServerSession, None]) -> str: return f"Name: {result.data.name}, Tags: {', '.join(result.data.tags)}" return f"User {result.action}" # pragma: no cover - async def multiselect_callback(context: RequestContext[ClientSession, Any], params: ElicitRequestParams): + async def multiselect_callback(context: RequestContext[ClientSession], params: ElicitRequestParams): if "Please provide tags" in params.message: return ElicitResult(action="accept", content={"name": "Test", "tags": ["tag1", "tag2"]}) return ElicitResult(action="decline") # pragma: no cover @@ -253,7 +240,7 @@ async def optional_multiselect_tool(ctx: Context[ServerSession, None]) -> str: return f"Name: {result.data.name}, Tags: {tags_str}" return f"User {result.action}" # pragma: no cover - async def optional_multiselect_callback(context: RequestContext[ClientSession, Any], params: ElicitRequestParams): + async def optional_multiselect_callback(context: RequestContext[ClientSession], params: ElicitRequestParams): if "Please provide optional tags" in params.message: return ElicitResult(action="accept", content={"name": "Test", "tags": ["tag1", "tag2"]}) return ElicitResult(action="decline") # pragma: no cover @@ -266,7 +253,7 @@ async def optional_multiselect_callback(context: RequestContext[ClientSession, A @pytest.mark.anyio async def test_elicitation_with_default_values(): """Test that default values work correctly in elicitation schemas and are included in JSON.""" - mcp = FastMCP(name="DefaultValuesServer") + mcp = MCPServer(name="DefaultValuesServer") class DefaultsSchema(BaseModel): name: str = Field(default="Guest", description="User name") @@ -287,10 +274,10 @@ async def defaults_tool(ctx: Context[ServerSession, None]) -> str: return f"User {result.action}" # First verify that defaults are present in the JSON schema sent to clients - async def callback_schema_verify(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + async def callback_schema_verify(context: RequestContext[ClientSession], params: ElicitRequestParams): # Verify the schema includes defaults assert isinstance(params, types.ElicitRequestFormParams), "Expected form mode elicitation" - schema = params.requestedSchema + schema = params.requested_schema props = schema["properties"] assert props["name"]["default"] == "Guest" @@ -309,7 +296,7 @@ async def callback_schema_verify(context: RequestContext[ClientSession, None], p ) # Test overriding defaults - async def callback_override(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + async def callback_override(context: RequestContext[ClientSession], params: ElicitRequestParams): return ElicitResult( action="accept", content={"email": "john@example.com", "name": "John", "age": 25, "subscribe": False} ) @@ -322,7 +309,7 @@ async def callback_override(context: RequestContext[ClientSession, None], params @pytest.mark.anyio async def test_elicitation_with_enum_titles(): """Test elicitation with enum schemas using oneOf/anyOf for titles.""" - mcp = FastMCP(name="ColorPreferencesApp") + mcp = MCPServer(name="ColorPreferencesApp") # Test single-select with titles using oneOf class FavoriteColorSchema(BaseModel): @@ -385,7 +372,7 @@ async def select_color_legacy(ctx: Context[ServerSession, None]) -> str: return f"User: {result.data.user_name}, Color: {result.data.color}" return f"User {result.action}" # pragma: no cover - async def enum_callback(context: RequestContext[ClientSession, Any], params: ElicitRequestParams): + async def enum_callback(context: RequestContext[ClientSession], params: ElicitRequestParams): if "colors" in params.message and "legacy" not in params.message: return ElicitResult(action="accept", content={"user_name": "Bob", "favorite_colors": ["red", "green"]}) elif "color" in params.message: diff --git a/tests/server/fastmcp/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py similarity index 97% rename from tests/server/fastmcp/test_func_metadata.py rename to tests/server/mcpserver/test_func_metadata.py index 61e524290..c57d1ee9f 100644 --- a/tests/server/fastmcp/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -5,14 +5,15 @@ # pyright: reportUnknownLambdaType=false from collections.abc import Callable from dataclasses import dataclass -from typing import Annotated, Any, Final, TypedDict +from typing import Annotated, Any, Final, NamedTuple, TypedDict import annotated_types import pytest from dirty_equals import IsPartialDict from pydantic import BaseModel, Field -from mcp.server.fastmcp.utilities.func_metadata import func_metadata +from mcp.server.mcpserver.exceptions import InvalidSignature +from mcp.server.mcpserver.utilities.func_metadata import func_metadata from mcp.types import CallToolResult @@ -447,8 +448,7 @@ def test_complex_function_json_schema(): def test_str_vs_int(): - """ - Test that string values are kept as strings even when they contain numbers, + """Test that string values are kept as strings even when they contain numbers, while numbers are parsed correctly. """ @@ -462,8 +462,7 @@ def func_with_str_and_int(a: str, b: int): # pragma: no cover def test_str_annotation_preserves_json_string(): - """ - Regression test for PR #1113: Ensure that when a parameter is annotated as str, + """Regression test for PR #1113: Ensure that when a parameter is annotated as str, valid JSON strings are NOT parsed into Python objects. This test would fail before the fix (JSON string would be parsed to dict) @@ -513,12 +512,11 @@ def process_json_config(config: str, enabled: bool = True) -> str: # pragma: no @pytest.mark.anyio async def test_str_annotation_runtime_validation(): - """ - Regression test for PR #1113: Test runtime validation with string parameters + """Regression test for PR #1113: Test runtime validation with string parameters containing valid JSON to ensure they are passed as strings, not parsed objects. """ - def handle_json_payload(payload: str, strict_mode: bool = False) -> str: # pragma: no cover + def handle_json_payload(payload: str, strict_mode: bool = False) -> str: """Function that processes a JSON payload as a string.""" # This function expects to receive the raw JSON string # It might parse it later after validation or logging @@ -558,7 +556,6 @@ def handle_json_payload(payload: str, strict_mode: bool = False) -> str: # prag def test_structured_output_requires_return_annotation(): """Test that structured_output=True requires a return annotation""" - from mcp.server.fastmcp.exceptions import InvalidSignature def func_no_annotation(): # pragma: no cover return "hello" @@ -836,7 +833,7 @@ def func_returning_unannotated() -> UnannotatedClass: # pragma: no cover def test_tool_call_result_is_unstructured_and_not_converted(): - def func_returning_call_tool_result() -> CallToolResult: # pragma: no cover + def func_returning_call_tool_result() -> CallToolResult: return CallToolResult(content=[]) meta = func_metadata(func_returning_call_tool_result) @@ -849,8 +846,8 @@ def test_tool_call_result_annotated_is_structured_and_converted(): class PersonClass(BaseModel): name: str - def func_returning_annotated_tool_call_result() -> Annotated[CallToolResult, PersonClass]: # pragma: no cover - return CallToolResult(content=[], structuredContent={"name": "Brandon"}) + def func_returning_annotated_tool_call_result() -> Annotated[CallToolResult, PersonClass]: + return CallToolResult(content=[], structured_content={"name": "Brandon"}) meta = func_metadata(func_returning_annotated_tool_call_result) @@ -869,8 +866,8 @@ def test_tool_call_result_annotated_is_structured_and_invalid(): class PersonClass(BaseModel): name: str - def func_returning_annotated_tool_call_result() -> Annotated[CallToolResult, PersonClass]: # pragma: no cover - return CallToolResult(content=[], structuredContent={"person": "Brandon"}) + def func_returning_annotated_tool_call_result() -> Annotated[CallToolResult, PersonClass]: + return CallToolResult(content=[], structured_content={"person": "Brandon"}) meta = func_metadata(func_returning_annotated_tool_call_result) @@ -881,8 +878,6 @@ def func_returning_annotated_tool_call_result() -> Annotated[CallToolResult, Per def test_tool_call_result_in_optional_is_rejected(): """Test that Optional[CallToolResult] raises InvalidSignature""" - from mcp.server.fastmcp.exceptions import InvalidSignature - def func_optional_call_tool_result() -> CallToolResult | None: # pragma: no cover return CallToolResult(content=[]) @@ -896,8 +891,6 @@ def func_optional_call_tool_result() -> CallToolResult | None: # pragma: no cov def test_tool_call_result_in_union_is_rejected(): """Test that Union[str, CallToolResult] raises InvalidSignature""" - from mcp.server.fastmcp.exceptions import InvalidSignature - def func_union_call_tool_result() -> str | CallToolResult: # pragma: no cover return CallToolResult(content=[]) @@ -910,7 +903,6 @@ def func_union_call_tool_result() -> str | CallToolResult: # pragma: no cover def test_tool_call_result_in_pipe_union_is_rejected(): """Test that str | CallToolResult raises InvalidSignature""" - from mcp.server.fastmcp.exceptions import InvalidSignature def func_pipe_union_call_tool_result() -> str | CallToolResult: # pragma: no cover return CallToolResult(content=[]) @@ -985,9 +977,6 @@ def func_nested() -> PersonWithAddress: # pragma: no cover def test_structured_output_unserializable_type_error(): """Test error when structured_output=True is used with unserializable types""" - from typing import NamedTuple - - from mcp.server.fastmcp.exceptions import InvalidSignature # Test with a class that has non-serializable default values class ConfigWithCallable: @@ -1103,7 +1092,7 @@ def func_with_reserved_names( # pragma: no cover async def test_basemodel_reserved_names_validation(): """Test that validation and calling works with reserved parameter names""" - def func_with_reserved_names( # pragma: no cover + def func_with_reserved_names( model_dump: str, model_validate: int, dict: list[str], @@ -1185,8 +1174,6 @@ def func_with_reserved_json( # pragma: no cover def test_disallowed_type_qualifier(): - from mcp.server.fastmcp.exceptions import InvalidSignature - def func_disallowed_qualifier() -> Final[int]: # type: ignore pass # pragma: no cover diff --git a/tests/server/fastmcp/test_integration.py b/tests/server/mcpserver/test_integration.py similarity index 82% rename from tests/server/fastmcp/test_integration.py rename to tests/server/mcpserver/test_integration.py index b1cefca29..c4ea2dad6 100644 --- a/tests/server/fastmcp/test_integration.py +++ b/tests/server/mcpserver/test_integration.py @@ -1,7 +1,6 @@ -""" -Integration tests for FastMCP server functionality. +"""Integration tests for MCPServer server functionality. -These tests validate the proper functioning of FastMCP features using focused, +These tests validate the proper functioning of MCPServer features using focused, single-feature servers across different transports (SSE and StreamableHTTP). """ # TODO(Marcelo): The `examples` package is not being imported as package. We need to solve this. @@ -17,8 +16,7 @@ import pytest import uvicorn -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from pydantic import AnyUrl +from inline_snapshot import snapshot from examples.snippets.servers import ( basic_prompt, @@ -26,7 +24,7 @@ basic_tool, completion, elicitation, - fastmcp_quickstart, + mcpserver_quickstart, notifications, sampling, structured_output, @@ -34,9 +32,8 @@ ) from mcp.client.session import ClientSession from mcp.client.sse import sse_client -from mcp.client.streamable_http import GetSessionIdCallback, streamablehttp_client -from mcp.shared.context import RequestContext -from mcp.shared.message import SessionMessage +from mcp.client.streamable_http import streamable_http_client +from mcp.shared._context import RequestContext from mcp.shared.session import RequestResponder from mcp.types import ( ClientResult, @@ -51,8 +48,10 @@ NotificationParams, ProgressNotification, ProgressNotificationParams, + PromptReference, ReadResourceResult, ResourceListChangedNotification, + ResourceTemplateReference, ServerNotification, ServerRequest, TextContent, @@ -76,14 +75,14 @@ async def handle_generic_notification( ) -> None: """Handle any server notification and route to appropriate handler.""" if isinstance(message, ServerNotification): # pragma: no branch - if isinstance(message.root, ProgressNotification): - self.progress_notifications.append(message.root.params) - elif isinstance(message.root, LoggingMessageNotification): - self.log_messages.append(message.root.params) - elif isinstance(message.root, ResourceListChangedNotification): - self.resource_notifications.append(message.root.params) - elif isinstance(message.root, ToolListChangedNotification): # pragma: no cover - self.tool_notifications.append(message.root.params) + if isinstance(message, ProgressNotification): + self.progress_notifications.append(message.params) + elif isinstance(message, LoggingMessageNotification): + self.log_messages.append(message.params) + elif isinstance(message, ResourceListChangedNotification): + self.resource_notifications.append(message.params) + elif isinstance(message, ToolListChangedNotification): # pragma: no cover + self.tool_notifications.append(message.params) # Common fixtures @@ -120,8 +119,8 @@ def run_server_with_transport(module_name: str, port: int, transport: str) -> No mcp = completion.mcp elif module_name == "notifications": mcp = notifications.mcp - elif module_name == "fastmcp_quickstart": - mcp = fastmcp_quickstart.mcp + elif module_name == "mcpserver_quickstart": + mcp = mcpserver_quickstart.mcp elif module_name == "structured_output": mcp = structured_output.mcp else: @@ -179,40 +178,14 @@ def create_client_for_transport(transport: str, server_url: str): return sse_client(endpoint) elif transport == "streamable-http": endpoint = f"{server_url}/mcp" - return streamablehttp_client(endpoint) + return streamable_http_client(endpoint) else: # pragma: no cover raise ValueError(f"Invalid transport: {transport}") -def unpack_streams( - client_streams: tuple[MemoryObjectReceiveStream[SessionMessage | Exception], MemoryObjectSendStream[SessionMessage]] - | tuple[ - MemoryObjectReceiveStream[SessionMessage | Exception], - MemoryObjectSendStream[SessionMessage], - GetSessionIdCallback, - ], -): - """Unpack client streams handling different return values from SSE vs StreamableHTTP. - - SSE client returns (read_stream, write_stream) - StreamableHTTP client returns (read_stream, write_stream, session_id_callback) - - Args: - client_streams: Tuple from client context manager - - Returns: - Tuple of (read_stream, write_stream) - """ - if len(client_streams) == 2: - return client_streams - else: - read_stream, write_stream, _ = client_streams - return read_stream, write_stream - - # Callback functions for testing async def sampling_callback( - context: RequestContext[ClientSession, None], params: CreateMessageRequestParams + context: RequestContext[ClientSession], params: CreateMessageRequestParams ) -> CreateMessageResult: """Sampling callback for tests.""" return CreateMessageResult( @@ -225,7 +198,7 @@ async def sampling_callback( ) -async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): +async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams): """Elicitation callback for tests.""" # For restaurant booking test if "No tables available" in params.message: @@ -252,13 +225,12 @@ async def test_basic_tools(server_transport: str, server_url: str) -> None: transport = server_transport client_cm = create_client_for_transport(transport, server_url) - async with client_cm as client_streams: - read_stream, write_stream = unpack_streams(client_streams) + async with client_cm as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: # Test initialization result = await session.initialize() assert isinstance(result, InitializeResult) - assert result.serverInfo.name == "Tool Example" + assert result.server_info.name == "Tool Example" assert result.capabilities.tools is not None # Test sum tool @@ -289,24 +261,23 @@ async def test_basic_resources(server_transport: str, server_url: str) -> None: transport = server_transport client_cm = create_client_for_transport(transport, server_url) - async with client_cm as client_streams: - read_stream, write_stream = unpack_streams(client_streams) + async with client_cm as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: # Test initialization result = await session.initialize() assert isinstance(result, InitializeResult) - assert result.serverInfo.name == "Resource Example" + assert result.server_info.name == "Resource Example" assert result.capabilities.resources is not None # Test document resource - doc_content = await session.read_resource(AnyUrl("file://documents/readme")) + doc_content = await session.read_resource("file://documents/readme") assert isinstance(doc_content, ReadResourceResult) assert len(doc_content.contents) == 1 assert isinstance(doc_content.contents[0], TextResourceContents) assert "Content of readme" in doc_content.contents[0].text # Test settings resource - settings_content = await session.read_resource(AnyUrl("config://settings")) + settings_content = await session.read_resource("config://settings") assert isinstance(settings_content, ReadResourceResult) assert len(settings_content.contents) == 1 assert isinstance(settings_content.contents[0], TextResourceContents) @@ -330,13 +301,12 @@ async def test_basic_prompts(server_transport: str, server_url: str) -> None: transport = server_transport client_cm = create_client_for_transport(transport, server_url) - async with client_cm as client_streams: - read_stream, write_stream = unpack_streams(client_streams) + async with client_cm as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: # Test initialization result = await session.initialize() assert isinstance(result, InitializeResult) - assert result.serverInfo.name == "Prompt Example" + assert result.server_info.name == "Prompt Example" assert result.capabilities.prompts is not None # Test review_code prompt @@ -390,13 +360,12 @@ async def message_handler(message: RequestResponder[ServerRequest, ClientResult] client_cm = create_client_for_transport(transport, server_url) - async with client_cm as client_streams: - read_stream, write_stream = unpack_streams(client_streams) + async with client_cm as (read_stream, write_stream): async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session: # Test initialization result = await session.initialize() assert isinstance(result, InitializeResult) - assert result.serverInfo.name == "Progress Example" + assert result.server_info.name == "Progress Example" # Test progress callback progress_updates = [] @@ -411,10 +380,7 @@ async def progress_callback(progress: float, total: float | None, message: str | {"task_name": "Test Task", "steps": steps}, progress_callback=progress_callback, ) - - assert len(tool_result.content) == 1 - assert isinstance(tool_result.content[0], TextContent) - assert "Task 'Test Task' completed" in tool_result.content[0].text + assert tool_result.content == snapshot([TextContent(text="Task 'Test Task' completed")]) # Verify progress updates assert len(progress_updates) == steps @@ -443,13 +409,12 @@ async def test_sampling(server_transport: str, server_url: str) -> None: transport = server_transport client_cm = create_client_for_transport(transport, server_url) - async with client_cm as client_streams: - read_stream, write_stream = unpack_streams(client_streams) + async with client_cm as (read_stream, write_stream): async with ClientSession(read_stream, write_stream, sampling_callback=sampling_callback) as session: # Test initialization result = await session.initialize() assert isinstance(result, InitializeResult) - assert result.serverInfo.name == "Sampling Example" + assert result.server_info.name == "Sampling Example" assert result.capabilities.tools is not None # Test sampling tool @@ -474,13 +439,12 @@ async def test_elicitation(server_transport: str, server_url: str) -> None: transport = server_transport client_cm = create_client_for_transport(transport, server_url) - async with client_cm as client_streams: - read_stream, write_stream = unpack_streams(client_streams) + async with client_cm as (read_stream, write_stream): async with ClientSession(read_stream, write_stream, elicitation_callback=elicitation_callback) as session: # Test initialization result = await session.initialize() assert isinstance(result, InitializeResult) - assert result.serverInfo.name == "Elicitation Example" + assert result.server_info.name == "Elicitation Example" # Test booking with unavailable date (triggers elicitation) booking_result = await session.call_tool( @@ -531,13 +495,12 @@ async def message_handler(message: RequestResponder[ServerRequest, ClientResult] client_cm = create_client_for_transport(transport, server_url) - async with client_cm as client_streams: - read_stream, write_stream = unpack_streams(client_streams) + async with client_cm as (read_stream, write_stream): async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session: # Test initialization result = await session.initialize() assert isinstance(result, InitializeResult) - assert result.serverInfo.name == "Notifications Example" + assert result.server_info.name == "Notifications Example" # Call tool that generates notifications tool_result = await session.call_tool("process_data", {"data": "test_data"}) @@ -572,19 +535,16 @@ async def test_completion(server_transport: str, server_url: str) -> None: transport = server_transport client_cm = create_client_for_transport(transport, server_url) - async with client_cm as client_streams: - read_stream, write_stream = unpack_streams(client_streams) + async with client_cm as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: # Test initialization result = await session.initialize() assert isinstance(result, InitializeResult) - assert result.serverInfo.name == "Example" + assert result.server_info.name == "Example" assert result.capabilities.resources is not None assert result.capabilities.prompts is not None # Test resource completion - from mcp.types import ResourceTemplateReference - completion_result = await session.complete( ref=ResourceTemplateReference(type="ref/resource", uri="github://repos/{owner}/{repo}"), argument={"name": "repo", "value": ""}, @@ -600,8 +560,6 @@ async def test_completion(server_transport: str, server_url: str) -> None: assert "specification" in completion_result.completion.values # Test prompt completion - from mcp.types import PromptReference - completion_result = await session.complete( ref=PromptReference(type="ref/prompt", name="review_code"), argument={"name": "language", "value": "py"}, @@ -614,28 +572,27 @@ async def test_completion(server_transport: str, server_url: str) -> None: assert all(lang.startswith("py") for lang in completion_result.completion.values) -# Test FastMCP quickstart example +# Test MCPServer quickstart example @pytest.mark.anyio @pytest.mark.parametrize( "server_transport", [ - ("fastmcp_quickstart", "sse"), - ("fastmcp_quickstart", "streamable-http"), + ("mcpserver_quickstart", "sse"), + ("mcpserver_quickstart", "streamable-http"), ], indirect=True, ) -async def test_fastmcp_quickstart(server_transport: str, server_url: str) -> None: - """Test FastMCP quickstart example.""" +async def test_mcpserver_quickstart(server_transport: str, server_url: str) -> None: + """Test MCPServer quickstart example.""" transport = server_transport client_cm = create_client_for_transport(transport, server_url) - async with client_cm as client_streams: - read_stream, write_stream = unpack_streams(client_streams) + async with client_cm as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: # Test initialization result = await session.initialize() assert isinstance(result, InitializeResult) - assert result.serverInfo.name == "Demo" + assert result.server_info.name == "Demo" # Test add tool tool_result = await session.call_tool("add", {"a": 10, "b": 20}) @@ -644,9 +601,7 @@ async def test_fastmcp_quickstart(server_transport: str, server_url: str) -> Non assert tool_result.content[0].text == "30" # Test greeting resource directly - from pydantic import AnyUrl - - resource_result = await session.read_resource(AnyUrl("greeting://Alice")) + resource_result = await session.read_resource("greeting://Alice") assert len(resource_result.contents) == 1 assert isinstance(resource_result.contents[0], TextResourceContents) assert resource_result.contents[0].text == "Hello, Alice!" @@ -667,13 +622,12 @@ async def test_structured_output(server_transport: str, server_url: str) -> None transport = server_transport client_cm = create_client_for_transport(transport, server_url) - async with client_cm as client_streams: - read_stream, write_stream = unpack_streams(client_streams) + async with client_cm as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: # Test initialization result = await session.initialize() assert isinstance(result, InitializeResult) - assert result.serverInfo.name == "Structured Output Example" + assert result.server_info.name == "Structured Output Example" # Test get_weather tool weather_result = await session.call_tool("get_weather", {"city": "New York"}) diff --git a/tests/server/fastmcp/test_parameter_descriptions.py b/tests/server/mcpserver/test_parameter_descriptions.py similarity index 86% rename from tests/server/fastmcp/test_parameter_descriptions.py rename to tests/server/mcpserver/test_parameter_descriptions.py index 9f2386894..ec9f22c25 100644 --- a/tests/server/fastmcp/test_parameter_descriptions.py +++ b/tests/server/mcpserver/test_parameter_descriptions.py @@ -3,12 +3,12 @@ import pytest from pydantic import Field -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer @pytest.mark.anyio async def test_parameter_descriptions(): - mcp = FastMCP("Test Server") + mcp = MCPServer("Test Server") @mcp.tool() def greet( @@ -23,7 +23,7 @@ def greet( tool = tools[0] # Check that parameter descriptions are present in the schema - properties = tool.inputSchema["properties"] + properties = tool.input_schema["properties"] assert "name" in properties assert properties["name"]["description"] == "The name to greet" assert "title" in properties diff --git a/tests/server/fastmcp/test_server.py b/tests/server/mcpserver/test_server.py similarity index 61% rename from tests/server/fastmcp/test_server.py rename to tests/server/mcpserver/test_server.py index 3935f3bd1..cfbe6587b 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -1,134 +1,97 @@ import base64 from pathlib import Path -from typing import TYPE_CHECKING, Any -from unittest.mock import patch +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch import pytest -from pydantic import AnyUrl, BaseModel +from inline_snapshot import snapshot +from pydantic import BaseModel +from starlette.applications import Starlette from starlette.routing import Mount, Route -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.fastmcp.prompts.base import Message, UserMessage -from mcp.server.fastmcp.resources import FileResource, FunctionResource -from mcp.server.fastmcp.utilities.types import Audio, Image +from mcp.client import Client +from mcp.server.context import ServerRequestContext +from mcp.server.experimental.request_context import Experimental +from mcp.server.mcpserver import Context, MCPServer +from mcp.server.mcpserver.exceptions import ToolError +from mcp.server.mcpserver.prompts.base import Message, UserMessage +from mcp.server.mcpserver.resources import FileResource, FunctionResource +from mcp.server.mcpserver.utilities.types import Audio, Image from mcp.server.session import ServerSession from mcp.server.transport_security import TransportSecuritySettings -from mcp.shared.exceptions import McpError -from mcp.shared.memory import ( - create_connected_server_and_client_session as client_session, -) +from mcp.shared.exceptions import MCPError from mcp.types import ( AudioContent, BlobResourceContents, + Completion, + CompletionArgument, + CompletionContext, ContentBlock, EmbeddedResource, + GetPromptResult, + Icon, ImageContent, + ListPromptsResult, + Prompt, + PromptArgument, + PromptMessage, + PromptReference, + ReadResourceResult, + Resource, + ResourceTemplate, TextContent, TextResourceContents, ) -if TYPE_CHECKING: - from mcp.server.fastmcp import Context +pytestmark = pytest.mark.anyio class TestServer: - @pytest.mark.anyio async def test_create_server(self): - mcp = FastMCP(instructions="Server instructions") - assert mcp.name == "FastMCP" + mcp = MCPServer( + title="MCPServer Server", + description="Server description", + instructions="Server instructions", + website_url="https://example.com/mcp_server", + version="1.0", + icons=[Icon(src="https://example.com/icon.png", mime_type="image/png", sizes=["48x48", "96x96"])], + ) + assert mcp.name == "mcp-server" + assert mcp.title == "MCPServer Server" + assert mcp.description == "Server description" assert mcp.instructions == "Server instructions" + assert mcp.website_url == "https://example.com/mcp_server" + assert mcp.version == "1.0" + assert isinstance(mcp.icons, list) + assert len(mcp.icons) == 1 + assert mcp.icons[0].src == "https://example.com/icon.png" - @pytest.mark.anyio - async def test_normalize_path(self): - """Test path normalization for mount paths.""" - mcp = FastMCP() - - # Test root path - assert mcp._normalize_path("/", "/messages/") == "/messages/" - - # Test path with trailing slash - assert mcp._normalize_path("/github/", "/messages/") == "/github/messages/" - - # Test path without trailing slash - assert mcp._normalize_path("/github", "/messages/") == "/github/messages/" - - # Test endpoint without leading slash - assert mcp._normalize_path("/github", "messages/") == "/github/messages/" - - # Test both with trailing/leading slashes - assert mcp._normalize_path("/api/", "/v1/") == "/api/v1/" - - @pytest.mark.anyio - async def test_sse_app_with_mount_path(self): - """Test SSE app creation with different mount paths.""" - # Test with default mount path - mcp = FastMCP() - with patch.object(mcp, "_normalize_path", return_value="/messages/") as mock_normalize: - mcp.sse_app() - # Verify _normalize_path was called with correct args - mock_normalize.assert_called_once_with("/", "/messages/") - - # Test with custom mount path in settings - mcp = FastMCP() - mcp.settings.mount_path = "/custom" - with patch.object(mcp, "_normalize_path", return_value="/custom/messages/") as mock_normalize: - mcp.sse_app() - # Verify _normalize_path was called with correct args - mock_normalize.assert_called_once_with("/custom", "/messages/") - - # Test with mount_path parameter - mcp = FastMCP() - with patch.object(mcp, "_normalize_path", return_value="/param/messages/") as mock_normalize: - mcp.sse_app(mount_path="/param") - # Verify _normalize_path was called with correct args - mock_normalize.assert_called_once_with("/param", "/messages/") - - @pytest.mark.anyio - async def test_starlette_routes_with_mount_path(self): - """Test that Starlette routes are correctly configured with mount path.""" - # Test with mount path in settings - mcp = FastMCP() - mcp.settings.mount_path = "/api" - app = mcp.sse_app() - - # Find routes by type - sse_routes = [r for r in app.routes if isinstance(r, Route)] - mount_routes = [r for r in app.routes if isinstance(r, Mount)] - - # Verify routes exist - assert len(sse_routes) == 1, "Should have one SSE route" - assert len(mount_routes) == 1, "Should have one mount route" + async def test_sse_app_returns_starlette_app(self): + """Test that sse_app returns a Starlette application with correct routes.""" + mcp = MCPServer("test") + # Use host="0.0.0.0" to avoid auto DNS protection + app = mcp.sse_app(host="0.0.0.0") - # Verify path values - assert sse_routes[0].path == "/sse", "SSE route path should be /sse" - assert mount_routes[0].path == "/messages", "Mount route path should be /messages" + assert isinstance(app, Starlette) - # Test with mount path as parameter - mcp = FastMCP() - app = mcp.sse_app(mount_path="/param") - - # Find routes by type + # Verify routes exist sse_routes = [r for r in app.routes if isinstance(r, Route)] mount_routes = [r for r in app.routes if isinstance(r, Mount)] - # Verify routes exist assert len(sse_routes) == 1, "Should have one SSE route" assert len(mount_routes) == 1, "Should have one mount route" + assert sse_routes[0].path == "/sse" + assert mount_routes[0].path == "/messages" - # Verify path values - assert sse_routes[0].path == "/sse", "SSE route path should be /sse" - assert mount_routes[0].path == "/messages", "Mount route path should be /messages" - - @pytest.mark.anyio async def test_non_ascii_description(self): - """Test that FastMCP handles non-ASCII characters in descriptions correctly""" - mcp = FastMCP() + """Test that MCPServer handles non-ASCII characters in descriptions correctly""" + mcp = MCPServer() @mcp.tool(description=("🌟 This tool uses emojis and UTF-8 characters: á é í ó ú ñ 漢字 🎉")) def hello_world(name: str = "世界") -> str: return f"¡Hola, {name}! 👋" - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: tools = await client.list_tools() assert len(tools.tools) == 1 tool = tools.tools[0] @@ -143,9 +106,8 @@ def hello_world(name: str = "世界") -> str: assert isinstance(content, TextContent) assert "¡Hola, 世界! 👋" == content.text - @pytest.mark.anyio async def test_add_tool_decorator(self): - mcp = FastMCP() + mcp = MCPServer() @mcp.tool() def sum(x: int, y: int) -> int: # pragma: no cover @@ -153,9 +115,8 @@ def sum(x: int, y: int) -> int: # pragma: no cover assert len(mcp._tool_manager.list_tools()) == 1 - @pytest.mark.anyio async def test_add_tool_decorator_incorrect_usage(self): - mcp = FastMCP() + mcp = MCPServer() with pytest.raises(TypeError, match="The @tool decorator was used incorrectly"): @@ -163,9 +124,8 @@ async def test_add_tool_decorator_incorrect_usage(self): def sum(x: int, y: int) -> int: # pragma: no cover return x + y - @pytest.mark.anyio async def test_add_resource_decorator(self): - mcp = FastMCP() + mcp = MCPServer() @mcp.resource("r://{x}") def get_data(x: str) -> str: # pragma: no cover @@ -173,9 +133,8 @@ def get_data(x: str) -> str: # pragma: no cover assert len(mcp._resource_manager._templates) == 1 - @pytest.mark.anyio async def test_add_resource_decorator_incorrect_usage(self): - mcp = FastMCP() + mcp = MCPServer() with pytest.raises(TypeError, match="The @resource decorator was used incorrectly"): @@ -185,49 +144,64 @@ def get_data(x: str) -> str: # pragma: no cover class TestDnsRebindingProtection: - """Tests for automatic DNS rebinding protection on localhost.""" - - def test_auto_enabled_for_127_0_0_1(self): - """DNS rebinding protection should auto-enable for host=127.0.0.1.""" - mcp = FastMCP(host="127.0.0.1") - assert mcp.settings.transport_security is not None - assert mcp.settings.transport_security.enable_dns_rebinding_protection is True - assert "127.0.0.1:*" in mcp.settings.transport_security.allowed_hosts - assert "localhost:*" in mcp.settings.transport_security.allowed_hosts - assert "http://127.0.0.1:*" in mcp.settings.transport_security.allowed_origins - assert "http://localhost:*" in mcp.settings.transport_security.allowed_origins - - def test_auto_enabled_for_localhost(self): - """DNS rebinding protection should auto-enable for host=localhost.""" - mcp = FastMCP(host="localhost") - assert mcp.settings.transport_security is not None - assert mcp.settings.transport_security.enable_dns_rebinding_protection is True - assert "127.0.0.1:*" in mcp.settings.transport_security.allowed_hosts - assert "localhost:*" in mcp.settings.transport_security.allowed_hosts - - def test_auto_enabled_for_ipv6_localhost(self): - """DNS rebinding protection should auto-enable for host=::1 (IPv6 localhost).""" - mcp = FastMCP(host="::1") - assert mcp.settings.transport_security is not None - assert mcp.settings.transport_security.enable_dns_rebinding_protection is True - assert "[::1]:*" in mcp.settings.transport_security.allowed_hosts - assert "http://[::1]:*" in mcp.settings.transport_security.allowed_origins - - def test_not_auto_enabled_for_other_hosts(self): - """DNS rebinding protection should NOT auto-enable for other hosts.""" - mcp = FastMCP(host="0.0.0.0") - assert mcp.settings.transport_security is None - - def test_explicit_settings_not_overridden(self): - """Explicit transport_security settings should not be overridden.""" + """Tests for automatic DNS rebinding protection on localhost. + + DNS rebinding protection is now configured in sse_app() and streamable_http_app() + based on the host parameter passed to those methods. + """ + + def test_auto_enabled_for_127_0_0_1_sse(self): + """DNS rebinding protection should auto-enable for host=127.0.0.1 in SSE app.""" + mcp = MCPServer() + # Call sse_app with host=127.0.0.1 to trigger auto-config + # We can't directly inspect the transport_security, but we can verify + # the app is created without error + app = mcp.sse_app(host="127.0.0.1") + assert app is not None + + def test_auto_enabled_for_127_0_0_1_streamable_http(self): + """DNS rebinding protection should auto-enable for host=127.0.0.1 in StreamableHTTP app.""" + mcp = MCPServer() + app = mcp.streamable_http_app(host="127.0.0.1") + assert app is not None + + def test_auto_enabled_for_localhost_sse(self): + """DNS rebinding protection should auto-enable for host=localhost in SSE app.""" + mcp = MCPServer() + app = mcp.sse_app(host="localhost") + assert app is not None + + def test_auto_enabled_for_ipv6_localhost_sse(self): + """DNS rebinding protection should auto-enable for host=::1 (IPv6 localhost) in SSE app.""" + mcp = MCPServer() + app = mcp.sse_app(host="::1") + assert app is not None + + def test_not_auto_enabled_for_other_hosts_sse(self): + """DNS rebinding protection should NOT auto-enable for other hosts in SSE app.""" + mcp = MCPServer() + app = mcp.sse_app(host="0.0.0.0") + assert app is not None + + def test_explicit_settings_not_overridden_sse(self): + """Explicit transport_security settings should not be overridden in SSE app.""" custom_settings = TransportSecuritySettings( enable_dns_rebinding_protection=False, ) - mcp = FastMCP(host="127.0.0.1", transport_security=custom_settings) - # Settings are copied by pydantic, so check values not identity - assert mcp.settings.transport_security is not None - assert mcp.settings.transport_security.enable_dns_rebinding_protection is False - assert mcp.settings.transport_security.allowed_hosts == [] + mcp = MCPServer() + # Explicit transport_security passed to sse_app should be used as-is + app = mcp.sse_app(host="127.0.0.1", transport_security=custom_settings) + assert app is not None + + def test_explicit_settings_not_overridden_streamable_http(self): + """Explicit transport_security settings should not be overridden in StreamableHTTP app.""" + custom_settings = TransportSecuritySettings( + enable_dns_rebinding_protection=False, + ) + mcp = MCPServer() + # Explicit transport_security passed to streamable_http_app should be used as-is + app = mcp.streamable_http_app(host="127.0.0.1", transport_security=custom_settings) + assert app is not None def tool_fn(x: int, y: int) -> int: @@ -249,128 +223,119 @@ def audio_tool_fn(path: str) -> Audio: def mixed_content_tool_fn() -> list[ContentBlock]: return [ TextContent(type="text", text="Hello"), - ImageContent(type="image", data="abc", mimeType="image/png"), - AudioContent(type="audio", data="def", mimeType="audio/wav"), + ImageContent(type="image", data="abc", mime_type="image/png"), + AudioContent(type="audio", data="def", mime_type="audio/wav"), ] class TestServerTools: - @pytest.mark.anyio async def test_add_tool(self): - mcp = FastMCP() + mcp = MCPServer() mcp.add_tool(tool_fn) mcp.add_tool(tool_fn) assert len(mcp._tool_manager.list_tools()) == 1 - @pytest.mark.anyio async def test_list_tools(self): - mcp = FastMCP() + mcp = MCPServer() mcp.add_tool(tool_fn) - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: tools = await client.list_tools() assert len(tools.tools) == 1 - @pytest.mark.anyio async def test_call_tool(self): - mcp = FastMCP() + mcp = MCPServer() mcp.add_tool(tool_fn) - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.call_tool("my_tool", {"arg1": "value"}) assert not hasattr(result, "error") assert len(result.content) > 0 - @pytest.mark.anyio async def test_tool_exception_handling(self): - mcp = FastMCP() + mcp = MCPServer() mcp.add_tool(error_tool_fn) - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.call_tool("error_tool_fn", {}) assert len(result.content) == 1 content = result.content[0] assert isinstance(content, TextContent) assert "Test error" in content.text - assert result.isError is True + assert result.is_error is True - @pytest.mark.anyio async def test_tool_error_handling(self): - mcp = FastMCP() + mcp = MCPServer() mcp.add_tool(error_tool_fn) - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.call_tool("error_tool_fn", {}) assert len(result.content) == 1 content = result.content[0] assert isinstance(content, TextContent) assert "Test error" in content.text - assert result.isError is True + assert result.is_error is True - @pytest.mark.anyio async def test_tool_error_details(self): """Test that exception details are properly formatted in the response""" - mcp = FastMCP() + mcp = MCPServer() mcp.add_tool(error_tool_fn) - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.call_tool("error_tool_fn", {}) content = result.content[0] assert isinstance(content, TextContent) assert isinstance(content.text, str) assert "Test error" in content.text - assert result.isError is True + assert result.is_error is True - @pytest.mark.anyio async def test_tool_return_value_conversion(self): - mcp = FastMCP() + mcp = MCPServer() mcp.add_tool(tool_fn) - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.call_tool("tool_fn", {"x": 1, "y": 2}) assert len(result.content) == 1 content = result.content[0] assert isinstance(content, TextContent) assert content.text == "3" # Check structured content - int return type should have structured output - assert result.structuredContent is not None - assert result.structuredContent == {"result": 3} + assert result.structured_content is not None + assert result.structured_content == {"result": 3} - @pytest.mark.anyio async def test_tool_image_helper(self, tmp_path: Path): # Create a test image image_path = tmp_path / "test.png" image_path.write_bytes(b"fake png data") - mcp = FastMCP() + mcp = MCPServer() mcp.add_tool(image_tool_fn) - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.call_tool("image_tool_fn", {"path": str(image_path)}) assert len(result.content) == 1 content = result.content[0] assert isinstance(content, ImageContent) assert content.type == "image" - assert content.mimeType == "image/png" + assert content.mime_type == "image/png" # Verify base64 encoding decoded = base64.b64decode(content.data) assert decoded == b"fake png data" # Check structured content - Image return type should NOT have structured output - assert result.structuredContent is None + assert result.structured_content is None - @pytest.mark.anyio async def test_tool_audio_helper(self, tmp_path: Path): # Create a test audio audio_path = tmp_path / "test.wav" audio_path.write_bytes(b"fake wav data") - mcp = FastMCP() + mcp = MCPServer() mcp.add_tool(audio_tool_fn) - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.call_tool("audio_tool_fn", {"path": str(audio_path)}) assert len(result.content) == 1 content = result.content[0] assert isinstance(content, AudioContent) assert content.type == "audio" - assert content.mimeType == "audio/wav" + assert content.mime_type == "audio/wav" # Verify base64 encoding decoded = base64.b64decode(content.data) assert decoded == b"fake wav data" # Check structured content - Image return type should NOT have structured output - assert result.structuredContent is None + assert result.structured_content is None @pytest.mark.parametrize( "filename,expected_mime_type", @@ -384,46 +349,44 @@ async def test_tool_audio_helper(self, tmp_path: Path): ("test.unknown", "application/octet-stream"), # Unknown extension fallback ], ) - @pytest.mark.anyio async def test_tool_audio_suffix_detection(self, tmp_path: Path, filename: str, expected_mime_type: str): """Test that Audio helper correctly detects MIME types from file suffixes""" - mcp = FastMCP() + mcp = MCPServer() mcp.add_tool(audio_tool_fn) # Create a test audio file with the specific extension audio_path = tmp_path / filename audio_path.write_bytes(b"fake audio data") - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.call_tool("audio_tool_fn", {"path": str(audio_path)}) assert len(result.content) == 1 content = result.content[0] assert isinstance(content, AudioContent) assert content.type == "audio" - assert content.mimeType == expected_mime_type + assert content.mime_type == expected_mime_type # Verify base64 encoding decoded = base64.b64decode(content.data) assert decoded == b"fake audio data" - @pytest.mark.anyio async def test_tool_mixed_content(self): - mcp = FastMCP() + mcp = MCPServer() mcp.add_tool(mixed_content_tool_fn) - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.call_tool("mixed_content_tool_fn", {}) assert len(result.content) == 3 content1, content2, content3 = result.content assert isinstance(content1, TextContent) assert content1.text == "Hello" assert isinstance(content2, ImageContent) - assert content2.mimeType == "image/png" + assert content2.mime_type == "image/png" assert content2.data == "abc" assert isinstance(content3, AudioContent) - assert content3.mimeType == "audio/wav" + assert content3.mime_type == "audio/wav" assert content3.data == "def" - assert result.structuredContent is not None - assert "result" in result.structuredContent - structured_result = result.structuredContent["result"] + assert result.structured_content is not None + assert "result" in result.structured_content + structured_result = result.structured_content["result"] assert len(structured_result) == 3 expected_content = [ @@ -436,7 +399,6 @@ async def test_tool_mixed_content(self): for key, value in expected.items(): assert structured_result[i][key] == value - @pytest.mark.anyio async def test_tool_mixed_list_with_audio_and_image(self, tmp_path: Path): """Test that lists containing Image objects and other types are handled correctly""" @@ -459,9 +421,9 @@ def mixed_list_fn() -> list: # type: ignore TextContent(type="text", text="direct content"), ] - mcp = FastMCP() + mcp = MCPServer() mcp.add_tool(mixed_list_fn) # type: ignore - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.call_tool("mixed_list_fn", {}) assert len(result.content) == 5 # Check text conversion @@ -471,12 +433,12 @@ def mixed_list_fn() -> list: # type: ignore # Check image conversion content2 = result.content[1] assert isinstance(content2, ImageContent) - assert content2.mimeType == "image/png" + assert content2.mime_type == "image/png" assert base64.b64decode(content2.data) == b"test image data" # Check audio conversion content3 = result.content[2] assert isinstance(content3, AudioContent) - assert content3.mimeType == "audio/wav" + assert content3.mime_type == "audio/wav" assert base64.b64decode(content3.data) == b"test audio data" # Check dict conversion content4 = result.content[3] @@ -487,9 +449,8 @@ def mixed_list_fn() -> list: # type: ignore assert isinstance(content5, TextContent) assert content5.text == "direct content" # Check structured content - untyped list with Image objects should NOT have structured output - assert result.structuredContent is None + assert result.structured_content is None - @pytest.mark.anyio async def test_tool_structured_output_basemodel(self): """Test tool with structured output returning BaseModel""" @@ -502,29 +463,28 @@ def get_user(user_id: int) -> UserOutput: """Get user by ID""" return UserOutput(name="John Doe", age=30) - mcp = FastMCP() + mcp = MCPServer() mcp.add_tool(get_user) - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: # Check that the tool has outputSchema tools = await client.list_tools() tool = next(t for t in tools.tools if t.name == "get_user") - assert tool.outputSchema is not None - assert tool.outputSchema["type"] == "object" - assert "name" in tool.outputSchema["properties"] - assert "age" in tool.outputSchema["properties"] + assert tool.output_schema is not None + assert tool.output_schema["type"] == "object" + assert "name" in tool.output_schema["properties"] + assert "age" in tool.output_schema["properties"] # Call the tool and check structured output result = await client.call_tool("get_user", {"user_id": 123}) - assert result.isError is False - assert result.structuredContent is not None - assert result.structuredContent == {"name": "John Doe", "age": 30, "active": True} + assert result.is_error is False + assert result.structured_content is not None + assert result.structured_content == {"name": "John Doe", "age": 30, "active": True} # Content should be JSON serialized version assert len(result.content) == 1 assert isinstance(result.content[0], TextContent) assert '"name": "John Doe"' in result.content[0].text - @pytest.mark.anyio async def test_tool_structured_output_primitive(self): """Test tool with structured output returning primitive type""" @@ -532,26 +492,25 @@ def calculate_sum(a: int, b: int) -> int: """Add two numbers""" return a + b - mcp = FastMCP() + mcp = MCPServer() mcp.add_tool(calculate_sum) - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: # Check that the tool has outputSchema tools = await client.list_tools() tool = next(t for t in tools.tools if t.name == "calculate_sum") - assert tool.outputSchema is not None + assert tool.output_schema is not None # Primitive types are wrapped - assert tool.outputSchema["type"] == "object" - assert "result" in tool.outputSchema["properties"] - assert tool.outputSchema["properties"]["result"]["type"] == "integer" + assert tool.output_schema["type"] == "object" + assert "result" in tool.output_schema["properties"] + assert tool.output_schema["properties"]["result"]["type"] == "integer" # Call the tool result = await client.call_tool("calculate_sum", {"a": 5, "b": 7}) - assert result.isError is False - assert result.structuredContent is not None - assert result.structuredContent == {"result": 12} + assert result.is_error is False + assert result.structured_content is not None + assert result.structured_content == {"result": 12} - @pytest.mark.anyio async def test_tool_structured_output_list(self): """Test tool with structured output returning list""" @@ -559,33 +518,31 @@ def get_numbers() -> list[int]: """Get a list of numbers""" return [1, 2, 3, 4, 5] - mcp = FastMCP() + mcp = MCPServer() mcp.add_tool(get_numbers) - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.call_tool("get_numbers", {}) - assert result.isError is False - assert result.structuredContent is not None - assert result.structuredContent == {"result": [1, 2, 3, 4, 5]} + assert result.is_error is False + assert result.structured_content is not None + assert result.structured_content == {"result": [1, 2, 3, 4, 5]} - @pytest.mark.anyio async def test_tool_structured_output_server_side_validation_error(self): """Test that server-side validation errors are handled properly""" def get_numbers() -> list[int]: return [1, 2, 3, 4, [5]] # type: ignore - mcp = FastMCP() + mcp = MCPServer() mcp.add_tool(get_numbers) - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.call_tool("get_numbers", {}) - assert result.isError is True - assert result.structuredContent is None + assert result.is_error is True + assert result.structured_content is None assert len(result.content) == 1 assert isinstance(result.content[0], TextContent) - @pytest.mark.anyio async def test_tool_structured_output_dict_str_any(self): """Test tool with dict[str, Any] structured output""" @@ -599,24 +556,25 @@ def get_metadata() -> dict[str, Any]: "config": {"nested": {"value": 123}}, } - mcp = FastMCP() + mcp = MCPServer() mcp.add_tool(get_metadata) - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: # Check schema tools = await client.list_tools() tool = next(t for t in tools.tools if t.name == "get_metadata") - assert tool.outputSchema is not None - assert tool.outputSchema["type"] == "object" + assert tool.output_schema is not None + assert tool.output_schema["type"] == "object" # dict[str, Any] should have minimal schema assert ( - "additionalProperties" not in tool.outputSchema or tool.outputSchema.get("additionalProperties") is True + "additionalProperties" not in tool.output_schema + or tool.output_schema.get("additionalProperties") is True ) # Call tool result = await client.call_tool("get_metadata", {}) - assert result.isError is False - assert result.structuredContent is not None + assert result.is_error is False + assert result.structured_content is not None expected = { "version": "1.0.0", "enabled": True, @@ -624,9 +582,8 @@ def get_metadata() -> dict[str, Any]: "tags": ["production", "stable"], "config": {"nested": {"value": 123}}, } - assert result.structuredContent == expected + assert result.structured_content == expected - @pytest.mark.anyio async def test_tool_structured_output_dict_str_typed(self): """Test tool with dict[str, T] structured output for specific T""" @@ -634,26 +591,25 @@ def get_settings() -> dict[str, str]: """Get settings as string dictionary""" return {"theme": "dark", "language": "en", "timezone": "UTC"} - mcp = FastMCP() + mcp = MCPServer() mcp.add_tool(get_settings) - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: # Check schema tools = await client.list_tools() tool = next(t for t in tools.tools if t.name == "get_settings") - assert tool.outputSchema is not None - assert tool.outputSchema["type"] == "object" - assert tool.outputSchema["additionalProperties"]["type"] == "string" + assert tool.output_schema is not None + assert tool.output_schema["type"] == "object" + assert tool.output_schema["additionalProperties"]["type"] == "string" # Call tool result = await client.call_tool("get_settings", {}) - assert result.isError is False - assert result.structuredContent == {"theme": "dark", "language": "en", "timezone": "UTC"} + assert result.is_error is False + assert result.structured_content == {"theme": "dark", "language": "en", "timezone": "UTC"} - @pytest.mark.anyio async def test_remove_tool(self): """Test removing a tool from the server.""" - mcp = FastMCP() + mcp = MCPServer() mcp.add_tool(tool_fn) # Verify tool exists @@ -665,25 +621,21 @@ async def test_remove_tool(self): # Verify tool is removed assert len(mcp._tool_manager.list_tools()) == 0 - @pytest.mark.anyio async def test_remove_nonexistent_tool(self): """Test that removing a non-existent tool raises ToolError.""" - from mcp.server.fastmcp.exceptions import ToolError - - mcp = FastMCP() + mcp = MCPServer() with pytest.raises(ToolError, match="Unknown tool: nonexistent"): mcp.remove_tool("nonexistent") - @pytest.mark.anyio async def test_remove_tool_and_list(self): """Test that a removed tool doesn't appear in list_tools.""" - mcp = FastMCP() + mcp = MCPServer() mcp.add_tool(tool_fn) mcp.add_tool(error_tool_fn) # Verify both tools exist - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: tools = await client.list_tools() assert len(tools.tools) == 2 tool_names = [t.name for t in tools.tools] @@ -694,21 +646,20 @@ async def test_remove_tool_and_list(self): mcp.remove_tool("tool_fn") # Verify only one tool remains - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: tools = await client.list_tools() assert len(tools.tools) == 1 assert tools.tools[0].name == "error_tool_fn" - @pytest.mark.anyio async def test_remove_tool_and_call(self): """Test that calling a removed tool fails appropriately.""" - mcp = FastMCP() + mcp = MCPServer() mcp.add_tool(tool_fn) # Verify tool works before removal - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.call_tool("tool_fn", {"x": 1, "y": 2}) - assert not result.isError + assert not result.is_error content = result.content[0] assert isinstance(content, TextContent) assert content.text == "3" @@ -717,112 +668,130 @@ async def test_remove_tool_and_call(self): mcp.remove_tool("tool_fn") # Verify calling removed tool returns an error - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.call_tool("tool_fn", {"x": 1, "y": 2}) - assert result.isError + assert result.is_error content = result.content[0] assert isinstance(content, TextContent) assert "Unknown tool" in content.text class TestServerResources: - @pytest.mark.anyio async def test_text_resource(self): - mcp = FastMCP() + mcp = MCPServer() def get_text(): return "Hello, world!" - resource = FunctionResource(uri=AnyUrl("resource://test"), name="test", fn=get_text) + resource = FunctionResource(uri="resource://test", name="test", fn=get_text) mcp.add_resource(resource) - async with client_session(mcp._mcp_server) as client: - result = await client.read_resource(AnyUrl("resource://test")) + async with Client(mcp) as client: + result = await client.read_resource("resource://test") + assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Hello, world!" - @pytest.mark.anyio + async def test_read_unknown_resource(self): + """Test that reading an unknown resource raises MCPError.""" + mcp = MCPServer() + + async with Client(mcp) as client: + with pytest.raises(MCPError, match="Unknown resource: unknown://missing"): + await client.read_resource("unknown://missing") + + async def test_read_resource_error(self): + """Test that resource read errors are properly wrapped in MCPError.""" + mcp = MCPServer() + + @mcp.resource("resource://failing") + def failing_resource(): + raise ValueError("Resource read failed") + + async with Client(mcp) as client: + with pytest.raises(MCPError, match="Error reading resource resource://failing"): + await client.read_resource("resource://failing") + async def test_binary_resource(self): - mcp = FastMCP() + mcp = MCPServer() def get_binary(): return b"Binary data" resource = FunctionResource( - uri=AnyUrl("resource://binary"), + uri="resource://binary", name="binary", fn=get_binary, mime_type="application/octet-stream", ) mcp.add_resource(resource) - async with client_session(mcp._mcp_server) as client: - result = await client.read_resource(AnyUrl("resource://binary")) + async with Client(mcp) as client: + result = await client.read_resource("resource://binary") + assert isinstance(result.contents[0], BlobResourceContents) assert result.contents[0].blob == base64.b64encode(b"Binary data").decode() - @pytest.mark.anyio async def test_file_resource_text(self, tmp_path: Path): - mcp = FastMCP() + mcp = MCPServer() # Create a text file text_file = tmp_path / "test.txt" text_file.write_text("Hello from file!") - resource = FileResource(uri=AnyUrl("file://test.txt"), name="test.txt", path=text_file) + resource = FileResource(uri="file://test.txt", name="test.txt", path=text_file) mcp.add_resource(resource) - async with client_session(mcp._mcp_server) as client: - result = await client.read_resource(AnyUrl("file://test.txt")) + async with Client(mcp) as client: + result = await client.read_resource("file://test.txt") + assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Hello from file!" - @pytest.mark.anyio async def test_file_resource_binary(self, tmp_path: Path): - mcp = FastMCP() + mcp = MCPServer() # Create a binary file binary_file = tmp_path / "test.bin" binary_file.write_bytes(b"Binary file data") resource = FileResource( - uri=AnyUrl("file://test.bin"), + uri="file://test.bin", name="test.bin", path=binary_file, mime_type="application/octet-stream", ) mcp.add_resource(resource) - async with client_session(mcp._mcp_server) as client: - result = await client.read_resource(AnyUrl("file://test.bin")) + async with Client(mcp) as client: + result = await client.read_resource("file://test.bin") + assert isinstance(result.contents[0], BlobResourceContents) assert result.contents[0].blob == base64.b64encode(b"Binary file data").decode() - @pytest.mark.anyio async def test_function_resource(self): - mcp = FastMCP() + mcp = MCPServer() @mcp.resource("function://test", name="test_get_data") def get_data() -> str: # pragma: no cover """get_data returns a string""" return "Hello, world!" - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: resources = await client.list_resources() assert len(resources.resources) == 1 resource = resources.resources[0] assert resource.description == "get_data returns a string" - assert resource.uri == AnyUrl("function://test") + assert resource.uri == "function://test" assert resource.name == "test_get_data" - assert resource.mimeType == "text/plain" + assert resource.mime_type == "text/plain" class TestServerResourceTemplates: - @pytest.mark.anyio async def test_resource_with_params(self): """Test that a resource with function parameters raises an error if the URI parameters don't match""" - mcp = FastMCP() + mcp = MCPServer() with pytest.raises(ValueError, match="Mismatch between URI parameters"): @@ -830,10 +799,9 @@ async def test_resource_with_params(self): def get_data_fn(param: str) -> str: # pragma: no cover return f"Data: {param}" - @pytest.mark.anyio async def test_resource_with_uri_params(self): """Test that a resource with URI parameters is automatically a template""" - mcp = FastMCP() + mcp = MCPServer() with pytest.raises(ValueError, match="Mismatch between URI parameters"): @@ -841,33 +809,31 @@ async def test_resource_with_uri_params(self): def get_data() -> str: # pragma: no cover return "Data" - @pytest.mark.anyio async def test_resource_with_untyped_params(self): """Test that a resource with untyped parameters raises an error""" - mcp = FastMCP() + mcp = MCPServer() @mcp.resource("resource://{param}") def get_data(param) -> str: # type: ignore # pragma: no cover return "Data" - @pytest.mark.anyio async def test_resource_matching_params(self): """Test that a resource with matching URI and function parameters works""" - mcp = FastMCP() + mcp = MCPServer() @mcp.resource("resource://{name}/data") def get_data(name: str) -> str: return f"Data for {name}" - async with client_session(mcp._mcp_server) as client: - result = await client.read_resource(AnyUrl("resource://test/data")) + async with Client(mcp) as client: + result = await client.read_resource("resource://test/data") + assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Data for test" - @pytest.mark.anyio async def test_resource_mismatched_params(self): """Test that mismatched parameters raise an error""" - mcp = FastMCP() + mcp = MCPServer() with pytest.raises(ValueError, match="Mismatch between URI parameters"): @@ -875,24 +841,23 @@ async def test_resource_mismatched_params(self): def get_data(user: str) -> str: # pragma: no cover return f"Data for {user}" - @pytest.mark.anyio async def test_resource_multiple_params(self): """Test that multiple parameters work correctly""" - mcp = FastMCP() + mcp = MCPServer() @mcp.resource("resource://{org}/{repo}/data") def get_data(org: str, repo: str) -> str: return f"Data for {org}/{repo}" - async with client_session(mcp._mcp_server) as client: - result = await client.read_resource(AnyUrl("resource://cursor/fastmcp/data")) + async with Client(mcp) as client: + result = await client.read_resource("resource://cursor/myrepo/data") + assert isinstance(result.contents[0], TextResourceContents) - assert result.contents[0].text == "Data for cursor/fastmcp" + assert result.contents[0].text == "Data for cursor/myrepo" - @pytest.mark.anyio async def test_resource_multiple_mismatched_params(self): """Test that mismatched parameters raise an error""" - mcp = FastMCP() + mcp = MCPServer() with pytest.raises(ValueError, match="Mismatch between URI parameters"): @@ -900,22 +865,22 @@ async def test_resource_multiple_mismatched_params(self): def get_data_mismatched(org: str, repo_2: str) -> str: # pragma: no cover return f"Data for {org}" - """Test that a resource with no parameters works as a regular resource""" # pragma: no cover - mcp = FastMCP() + """Test that a resource with no parameters works as a regular resource""" + mcp = MCPServer() @mcp.resource("resource://static") def get_static_data() -> str: return "Static data" - async with client_session(mcp._mcp_server) as client: - result = await client.read_resource(AnyUrl("resource://static")) + async with Client(mcp) as client: + result = await client.read_resource("resource://static") + assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Static data" - @pytest.mark.anyio async def test_template_to_resource_conversion(self): """Test that templates are properly converted to resources when accessed""" - mcp = FastMCP() + mcp = MCPServer() @mcp.resource("resource://{name}/data") def get_data(name: str) -> str: @@ -931,35 +896,112 @@ def get_data(name: str) -> str: result = await resource.read() assert result == "Data for test" - @pytest.mark.anyio async def test_resource_template_includes_mime_type(self): """Test that list resource templates includes the correct mimeType.""" - mcp = FastMCP() + mcp = MCPServer() @mcp.resource("resource://{user}/csv", mime_type="text/csv") def get_csv(user: str) -> str: return f"csv for {user}" templates = await mcp.list_resource_templates() - assert len(templates) == 1 - template = templates[0] + assert templates == snapshot( + [ + ResourceTemplate( + name="get_csv", uri_template="resource://{user}/csv", description="", mime_type="text/csv" + ) + ] + ) - assert hasattr(template, "mimeType") - assert template.mimeType == "text/csv" + async with Client(mcp) as client: + result = await client.read_resource("resource://bob/csv") + assert result == snapshot( + ReadResourceResult( + contents=[TextResourceContents(uri="resource://bob/csv", mime_type="text/csv", text="csv for bob")] + ) + ) - async with client_session(mcp._mcp_server) as client: - result = await client.read_resource(AnyUrl("resource://bob/csv")) - assert isinstance(result.contents[0], TextResourceContents) - assert result.contents[0].text == "csv for bob" + +class TestServerResourceMetadata: + """Test MCPServer @resource decorator meta parameter for list operations. + + Meta flows: @resource decorator -> resource/template storage -> list_resources/list_resource_templates. + Note: read_resource does NOT pass meta to protocol response (lowlevel/server.py only extracts content/mime_type). + """ + + async def test_resource_decorator_with_metadata(self): + """Test that @resource decorator accepts and passes meta parameter.""" + # Tests static resource flow: decorator -> FunctionResource -> list_resources (server.py:544,635,361) + mcp = MCPServer() + + @mcp.resource("resource://config", meta={"ui": {"component": "file-viewer"}, "priority": "high"}) + def get_config() -> str: ... # pragma: no branch + + resources = await mcp.list_resources() + assert resources == snapshot( + [ + Resource( + name="get_config", + uri="resource://config", + description="", + mime_type="text/plain", + meta={"ui": {"component": "file-viewer"}, "priority": "high"}, # type: ignore[reportCallIssue] + ) + ] + ) + + async def test_resource_template_decorator_with_metadata(self): + """Test that @resource decorator passes meta to templates.""" + # Tests template resource flow: decorator -> add_template() -> list_resource_templates (server.py:544,622,377) + mcp = MCPServer() + + @mcp.resource("resource://{city}/weather", meta={"api_version": "v2", "deprecated": False}) + def get_weather(city: str) -> str: ... # pragma: no branch + + templates = await mcp.list_resource_templates() + assert templates == snapshot( + [ + ResourceTemplate( + name="get_weather", + uri_template="resource://{city}/weather", + description="", + mime_type="text/plain", + meta={"api_version": "v2", "deprecated": False}, # type: ignore[reportCallIssue] + ) + ] + ) + + async def test_read_resource_returns_meta(self): + """Test that read_resource includes meta in response.""" + # Tests end-to-end: Resource.meta -> ReadResourceContents.meta -> protocol _meta (lowlevel/server.py:341,371) + mcp = MCPServer() + + @mcp.resource("resource://data", meta={"version": "1.0", "category": "config"}) + def get_data() -> str: + return "test data" + + async with Client(mcp) as client: + result = await client.read_resource("resource://data") + assert result == snapshot( + ReadResourceResult( + contents=[ + TextResourceContents( + uri="resource://data", + mime_type="text/plain", + meta={"version": "1.0", "category": "config"}, # type: ignore[reportUnknownMemberType] + text="test data", + ) + ] + ) + ) class TestContextInjection: """Test context injection in tools, resources, and prompts.""" - @pytest.mark.anyio async def test_context_detection(self): """Test that context parameters are properly detected.""" - mcp = FastMCP() + mcp = MCPServer() def tool_with_context(x: int, ctx: Context[ServerSession, None]) -> str: # pragma: no cover return f"Request {ctx.request_id}: {x}" @@ -967,17 +1009,16 @@ def tool_with_context(x: int, ctx: Context[ServerSession, None]) -> str: # prag tool = mcp._tool_manager.add_tool(tool_with_context) assert tool.context_kwarg == "ctx" - @pytest.mark.anyio async def test_context_injection(self): """Test that context is properly injected into tool calls.""" - mcp = FastMCP() + mcp = MCPServer() def tool_with_context(x: int, ctx: Context[ServerSession, None]) -> str: assert ctx.request_id is not None return f"Request {ctx.request_id}: {x}" mcp.add_tool(tool_with_context) - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.call_tool("tool_with_context", {"x": 42}) assert len(result.content) == 1 content = result.content[0] @@ -985,17 +1026,16 @@ def tool_with_context(x: int, ctx: Context[ServerSession, None]) -> str: assert "Request" in content.text assert "42" in content.text - @pytest.mark.anyio async def test_async_context(self): """Test that context works in async functions.""" - mcp = FastMCP() + mcp = MCPServer() async def async_tool(x: int, ctx: Context[ServerSession, None]) -> str: assert ctx.request_id is not None return f"Async request {ctx.request_id}: {x}" mcp.add_tool(async_tool) - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.call_tool("async_tool", {"x": 42}) assert len(result.content) == 1 content = result.content[0] @@ -1003,10 +1043,9 @@ async def async_tool(x: int, ctx: Context[ServerSession, None]) -> str: assert "Async request" in content.text assert "42" in content.text - @pytest.mark.anyio async def test_context_logging(self): """Test that context logging methods work.""" - mcp = FastMCP() + mcp = MCPServer() async def logging_tool(msg: str, ctx: Context[ServerSession, None]) -> str: await ctx.debug("Debug message") @@ -1018,7 +1057,7 @@ async def logging_tool(msg: str, ctx: Context[ServerSession, None]) -> str: mcp.add_tool(logging_tool) with patch("mcp.server.session.ServerSession.send_log_message") as mock_log: - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.call_tool("logging_tool", {"msg": "test"}) assert len(result.content) == 1 content = result.content[0] @@ -1026,51 +1065,29 @@ async def logging_tool(msg: str, ctx: Context[ServerSession, None]) -> str: assert "Logged messages for test" in content.text assert mock_log.call_count == 4 - mock_log.assert_any_call( - level="debug", - data="Debug message", - logger=None, - related_request_id="1", - ) - mock_log.assert_any_call( - level="info", - data="Info message", - logger=None, - related_request_id="1", - ) - mock_log.assert_any_call( - level="warning", - data="Warning message", - logger=None, - related_request_id="1", - ) - mock_log.assert_any_call( - level="error", - data="Error message", - logger=None, - related_request_id="1", - ) + mock_log.assert_any_call(level="debug", data="Debug message", logger=None, related_request_id="1") + mock_log.assert_any_call(level="info", data="Info message", logger=None, related_request_id="1") + mock_log.assert_any_call(level="warning", data="Warning message", logger=None, related_request_id="1") + mock_log.assert_any_call(level="error", data="Error message", logger=None, related_request_id="1") - @pytest.mark.anyio async def test_optional_context(self): """Test that context is optional.""" - mcp = FastMCP() + mcp = MCPServer() def no_context(x: int) -> int: return x * 2 mcp.add_tool(no_context) - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.call_tool("no_context", {"x": 21}) assert len(result.content) == 1 content = result.content[0] assert isinstance(content, TextContent) assert content.text == "42" - @pytest.mark.anyio async def test_context_resource_access(self): """Test that context can access resources.""" - mcp = FastMCP() + mcp = MCPServer() @mcp.resource("test://data") def test_resource() -> str: @@ -1084,17 +1101,16 @@ async def tool_with_resource(ctx: Context[ServerSession, None]) -> str: r = r_list[0] return f"Read resource: {r.content} with mime type {r.mime_type}" - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.call_tool("tool_with_resource", {}) assert len(result.content) == 1 content = result.content[0] assert isinstance(content, TextContent) assert "Read resource: resource data" in content.text - @pytest.mark.anyio async def test_resource_with_context(self): """Test that resources can receive context parameter.""" - mcp = FastMCP() + mcp = MCPServer() @mcp.resource("resource://context/{name}") def resource_with_context(name: str, ctx: Context[ServerSession, None]) -> str: @@ -1109,19 +1125,18 @@ def resource_with_context(name: str, ctx: Context[ServerSession, None]) -> str: assert hasattr(template, "context_kwarg") assert template.context_kwarg == "ctx" - # Test via client - async with client_session(mcp._mcp_server) as client: - result = await client.read_resource(AnyUrl("resource://context/test")) + async with Client(mcp) as client: + result = await client.read_resource("resource://context/test") + assert len(result.contents) == 1 content = result.contents[0] assert isinstance(content, TextResourceContents) # Should have either request_id or indication that context was injected assert "Resource test - context injected" == content.text - @pytest.mark.anyio async def test_resource_without_context(self): """Test that resources without context work normally.""" - mcp = FastMCP() + mcp = MCPServer() @mcp.resource("resource://nocontext/{name}") def resource_no_context(name: str) -> str: @@ -1134,18 +1149,21 @@ def resource_no_context(name: str) -> str: template = templates[0] assert template.context_kwarg is None - # Test via client - async with client_session(mcp._mcp_server) as client: - result = await client.read_resource(AnyUrl("resource://nocontext/test")) - assert len(result.contents) == 1 - content = result.contents[0] - assert isinstance(content, TextResourceContents) - assert content.text == "Resource test works" + async with Client(mcp) as client: + result = await client.read_resource("resource://nocontext/test") + assert result == snapshot( + ReadResourceResult( + contents=[ + TextResourceContents( + uri="resource://nocontext/test", mime_type="text/plain", text="Resource test works" + ) + ] + ) + ) - @pytest.mark.anyio async def test_resource_context_custom_name(self): """Test resource context with custom parameter name.""" - mcp = FastMCP() + mcp = MCPServer() @mcp.resource("resource://custom/{id}") def resource_custom_ctx(id: str, my_ctx: Context[ServerSession, None]) -> str: @@ -1159,18 +1177,21 @@ def resource_custom_ctx(id: str, my_ctx: Context[ServerSession, None]) -> str: template = templates[0] assert template.context_kwarg == "my_ctx" - # Test via client - async with client_session(mcp._mcp_server) as client: - result = await client.read_resource(AnyUrl("resource://custom/123")) - assert len(result.contents) == 1 - content = result.contents[0] - assert isinstance(content, TextResourceContents) - assert "Resource 123 with context" in content.text + async with Client(mcp) as client: + result = await client.read_resource("resource://custom/123") + assert result == snapshot( + ReadResourceResult( + contents=[ + TextResourceContents( + uri="resource://custom/123", mime_type="text/plain", text="Resource 123 with context" + ) + ] + ) + ) - @pytest.mark.anyio async def test_prompt_with_context(self): """Test that prompts can receive context parameter.""" - mcp = FastMCP() + mcp = MCPServer() @mcp.prompt("prompt_with_ctx") def prompt_with_context(text: str, ctx: Context[ServerSession, None]) -> str: @@ -1178,12 +1199,8 @@ def prompt_with_context(text: str, ctx: Context[ServerSession, None]) -> str: assert ctx is not None return f"Prompt '{text}' - context injected" - # Check if prompt has context parameter detection - prompts = mcp._prompt_manager.list_prompts() - assert len(prompts) == 1 - # Test via client - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: # Try calling without passing ctx explicitly result = await client.get_prompt("prompt_with_ctx", {"text": "test"}) # If this succeeds, check if context was injected @@ -1192,10 +1209,9 @@ def prompt_with_context(text: str, ctx: Context[ServerSession, None]) -> str: assert isinstance(content, TextContent) assert "Prompt 'test' - context injected" in content.text - @pytest.mark.anyio async def test_prompt_without_context(self): """Test that prompts without context work normally.""" - mcp = FastMCP() + mcp = MCPServer() @mcp.prompt("prompt_no_ctx") def prompt_no_context(text: str) -> str: @@ -1203,7 +1219,7 @@ def prompt_no_context(text: str) -> str: return f"Prompt '{text}' works" # Test via client - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.get_prompt("prompt_no_ctx", {"text": "test"}) assert len(result.messages) == 1 message = result.messages[0] @@ -1213,12 +1229,11 @@ def prompt_no_context(text: str) -> str: class TestServerPrompts: - """Test prompt functionality in FastMCP server.""" + """Test prompt functionality in MCPServer server.""" - @pytest.mark.anyio async def test_prompt_decorator(self): """Test that the prompt decorator registers prompts correctly.""" - mcp = FastMCP() + mcp = MCPServer() @mcp.prompt() def fn() -> str: @@ -1232,10 +1247,9 @@ def fn() -> str: assert isinstance(content[0].content, TextContent) assert content[0].content.text == "Hello, world!" - @pytest.mark.anyio async def test_prompt_decorator_with_name(self): """Test prompt decorator with custom name.""" - mcp = FastMCP() + mcp = MCPServer() @mcp.prompt(name="custom_name") def fn() -> str: @@ -1248,10 +1262,9 @@ def fn() -> str: assert isinstance(content[0].content, TextContent) assert content[0].content.text == "Hello, world!" - @pytest.mark.anyio async def test_prompt_decorator_with_description(self): """Test prompt decorator with custom description.""" - mcp = FastMCP() + mcp = MCPServer() @mcp.prompt(description="A custom description") def fn() -> str: @@ -1266,159 +1279,193 @@ def fn() -> str: def test_prompt_decorator_error(self): """Test error when decorator is used incorrectly.""" - mcp = FastMCP() + mcp = MCPServer() with pytest.raises(TypeError, match="decorator was used incorrectly"): @mcp.prompt # type: ignore - def fn() -> str: # pragma: no cover - return "Hello, world!" + def fn() -> str: ... # pragma: no branch - @pytest.mark.anyio async def test_list_prompts(self): """Test listing prompts through MCP protocol.""" - mcp = FastMCP() + mcp = MCPServer() @mcp.prompt() - def fn(name: str, optional: str = "default") -> str: # pragma: no cover - return f"Hello, {name}!" + def fn(name: str, optional: str = "default") -> str: ... # pragma: no branch - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.list_prompts() - assert result.prompts is not None - assert len(result.prompts) == 1 - prompt = result.prompts[0] - assert prompt.name == "fn" - assert prompt.arguments is not None - assert len(prompt.arguments) == 2 - assert prompt.arguments[0].name == "name" - assert prompt.arguments[0].required is True - assert prompt.arguments[1].name == "optional" - assert prompt.arguments[1].required is False - - @pytest.mark.anyio + assert result == snapshot( + ListPromptsResult( + prompts=[ + Prompt( + name="fn", + description="", + arguments=[ + PromptArgument(name="name", required=True), + PromptArgument(name="optional", required=False), + ], + ) + ] + ) + ) + async def test_get_prompt(self): """Test getting a prompt through MCP protocol.""" - mcp = FastMCP() + mcp = MCPServer() @mcp.prompt() def fn(name: str) -> str: return f"Hello, {name}!" - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.get_prompt("fn", {"name": "World"}) - assert len(result.messages) == 1 - message = result.messages[0] - assert message.role == "user" - content = message.content - assert isinstance(content, TextContent) - assert content.text == "Hello, World!" + assert result == snapshot( + GetPromptResult( + description="", + messages=[PromptMessage(role="user", content=TextContent(text="Hello, World!"))], + ) + ) - @pytest.mark.anyio async def test_get_prompt_with_description(self): """Test getting a prompt through MCP protocol.""" - mcp = FastMCP() + mcp = MCPServer() @mcp.prompt(description="Test prompt description") def fn(name: str) -> str: return f"Hello, {name}!" - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.get_prompt("fn", {"name": "World"}) assert result.description == "Test prompt description" - @pytest.mark.anyio - async def test_get_prompt_without_description(self): - """Test getting a prompt without description returns empty string.""" - mcp = FastMCP() - - @mcp.prompt() - def fn(name: str) -> str: - return f"Hello, {name}!" - - async with client_session(mcp._mcp_server) as client: - result = await client.get_prompt("fn", {"name": "World"}) - assert result.description == "" - - @pytest.mark.anyio async def test_get_prompt_with_docstring_description(self): """Test prompt uses docstring as description when not explicitly provided.""" - mcp = FastMCP() + mcp = MCPServer() @mcp.prompt() def fn(name: str) -> str: """This is the function docstring.""" return f"Hello, {name}!" - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.get_prompt("fn", {"name": "World"}) - assert result.description == "This is the function docstring." + assert result == snapshot( + GetPromptResult( + description="This is the function docstring.", + messages=[PromptMessage(role="user", content=TextContent(text="Hello, World!"))], + ) + ) - @pytest.mark.anyio async def test_get_prompt_with_resource(self): """Test getting a prompt that returns resource content.""" - mcp = FastMCP() + mcp = MCPServer() @mcp.prompt() def fn() -> Message: return UserMessage( content=EmbeddedResource( type="resource", - resource=TextResourceContents( - uri=AnyUrl("file://file.txt"), - text="File contents", - mimeType="text/plain", - ), + resource=TextResourceContents(uri="file://file.txt", text="File contents", mime_type="text/plain"), ) ) - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.get_prompt("fn") - assert len(result.messages) == 1 - message = result.messages[0] - assert message.role == "user" - content = message.content - assert isinstance(content, EmbeddedResource) - resource = content.resource - assert isinstance(resource, TextResourceContents) - assert resource.text == "File contents" - assert resource.mimeType == "text/plain" + assert result == snapshot( + GetPromptResult( + description="", + messages=[ + PromptMessage( + role="user", + content=EmbeddedResource( + resource=TextResourceContents( + uri="file://file.txt", mime_type="text/plain", text="File contents" + ) + ), + ) + ], + ) + ) - @pytest.mark.anyio async def test_get_unknown_prompt(self): """Test error when getting unknown prompt.""" - mcp = FastMCP() - async with client_session(mcp._mcp_server) as client: - with pytest.raises(McpError, match="Unknown prompt"): + mcp = MCPServer() + + async with Client(mcp) as client: + with pytest.raises(MCPError, match="Unknown prompt"): await client.get_prompt("unknown") - @pytest.mark.anyio async def test_get_prompt_missing_args(self): """Test error when required arguments are missing.""" - mcp = FastMCP() + mcp = MCPServer() @mcp.prompt() - def prompt_fn(name: str) -> str: # pragma: no cover - return f"Hello, {name}!" + def prompt_fn(name: str) -> str: ... # pragma: no branch - async with client_session(mcp._mcp_server) as client: - with pytest.raises(McpError, match="Missing required arguments"): + async with Client(mcp) as client: + with pytest.raises(MCPError, match="Missing required arguments"): await client.get_prompt("prompt_fn") +async def test_completion_decorator() -> None: + """Test that the completion decorator registers a working handler.""" + mcp = MCPServer() + + @mcp.completion() + async def handle_completion( + ref: PromptReference, argument: CompletionArgument, context: CompletionContext | None + ) -> Completion: + assert argument.name == "style" + return Completion(values=["bold", "italic", "underline"]) + + async with Client(mcp) as client: + ref = PromptReference(type="ref/prompt", name="test") + result = await client.complete(ref=ref, argument={"name": "style", "value": "b"}) + assert result.completion.values == ["bold", "italic", "underline"] + + def test_streamable_http_no_redirect() -> None: """Test that streamable HTTP routes are correctly configured.""" - mcp = FastMCP() + mcp = MCPServer() + # streamable_http_path defaults to "/mcp" app = mcp.streamable_http_app() # Find routes by type - streamable_http_app creates Route objects, not Mount objects - streamable_routes = [ - r - for r in app.routes - if isinstance(r, Route) and hasattr(r, "path") and r.path == mcp.settings.streamable_http_path - ] + streamable_routes = [r for r in app.routes if isinstance(r, Route) and hasattr(r, "path") and r.path == "/mcp"] # Verify routes exist assert len(streamable_routes) == 1, "Should have one streamable route" # Verify path values assert streamable_routes[0].path == "/mcp", "Streamable route path should be /mcp" + + +async def test_report_progress_passes_related_request_id(): + """Test that report_progress passes the request_id as related_request_id. + + Without related_request_id, the streamable HTTP transport cannot route + progress notifications to the correct SSE stream, causing them to be + silently dropped. See #953 and #2001. + """ + mock_session = AsyncMock() + mock_session.send_progress_notification = AsyncMock() + + request_context = ServerRequestContext( + request_id="req-abc-123", + session=mock_session, + meta={"progress_token": "tok-1"}, + lifespan_context=None, + experimental=Experimental(), + ) + + ctx = Context(request_context=request_context, mcp_server=MagicMock()) + + await ctx.report_progress(50, 100, message="halfway") + + mock_session.send_progress_notification.assert_awaited_once_with( + progress_token="tok-1", + progress=50, + total=100, + message="halfway", + related_request_id="req-abc-123", + ) diff --git a/tests/server/fastmcp/test_title.py b/tests/server/mcpserver/test_title.py similarity index 75% rename from tests/server/fastmcp/test_title.py rename to tests/server/mcpserver/test_title.py index 7cac57012..662464757 100644 --- a/tests/server/fastmcp/test_title.py +++ b/tests/server/mcpserver/test_title.py @@ -1,20 +1,43 @@ """Integration tests for title field functionality.""" import pytest -from pydantic import AnyUrl -from mcp.server.fastmcp import FastMCP -from mcp.server.fastmcp.resources import FunctionResource -from mcp.shared.memory import create_connected_server_and_client_session +from mcp import Client +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.resources import FunctionResource from mcp.shared.metadata_utils import get_display_name from mcp.types import Prompt, Resource, ResourceTemplate, Tool, ToolAnnotations +@pytest.mark.anyio +async def test_server_name_title_description_version(): + """Test that server title and description are set and retrievable correctly.""" + mcp = MCPServer( + name="TestServer", + title="Test Server Title", + description="This is a test server description.", + version="1.0", + ) + + assert mcp.title == "Test Server Title" + assert mcp.description == "This is a test server description." + assert mcp.version == "1.0" + + # Start server and connect client + async with Client(mcp) as client: + # Access initialization result from session + init_result = await client.session.initialize() + assert init_result.server_info.name == "TestServer" + assert init_result.server_info.title == "Test Server Title" + assert init_result.server_info.description == "This is a test server description." + assert init_result.server_info.version == "1.0" + + @pytest.mark.anyio async def test_tool_title_precedence(): """Test that tool title precedence works correctly: title > annotations.title > name.""" # Create server with various tool configurations - mcp = FastMCP(name="TitleTestServer") + mcp = MCPServer(name="TitleTestServer") # Tool with only name @mcp.tool(description="Basic tool") @@ -38,9 +61,7 @@ def tool_with_both(message: str) -> str: # pragma: no cover return message # Start server and connect client - async with create_connected_server_and_client_session(mcp._mcp_server) as client: - await client.initialize() - + async with Client(mcp) as client: # List tools tools_result = await client.list_tools() tools = {tool.name: tool for tool in tools_result.tools} @@ -69,7 +90,7 @@ def tool_with_both(message: str) -> str: # pragma: no cover @pytest.mark.anyio async def test_prompt_title(): """Test that prompt titles work correctly.""" - mcp = FastMCP(name="PromptTitleServer") + mcp = MCPServer(name="PromptTitleServer") # Prompt with only name @mcp.prompt(description="Basic prompt") @@ -82,9 +103,7 @@ def titled_prompt(topic: str) -> str: # pragma: no cover return f"Tell me about {topic}" # Start server and connect client - async with create_connected_server_and_client_session(mcp._mcp_server) as client: - await client.initialize() - + async with Client(mcp) as client: # List prompts prompts_result = await client.list_prompts() prompts = {prompt.name: prompt for prompt in prompts_result.prompts} @@ -104,14 +123,14 @@ def titled_prompt(topic: str) -> str: # pragma: no cover @pytest.mark.anyio async def test_resource_title(): """Test that resource titles work correctly.""" - mcp = FastMCP(name="ResourceTitleServer") + mcp = MCPServer(name="ResourceTitleServer") # Static resource without title def get_basic_data() -> str: # pragma: no cover return "Basic data" basic_resource = FunctionResource( - uri=AnyUrl("resource://basic"), + uri="resource://basic", name="basic_resource", description="Basic resource", fn=get_basic_data, @@ -123,7 +142,7 @@ def get_titled_data() -> str: # pragma: no cover return "Titled data" titled_resource = FunctionResource( - uri=AnyUrl("resource://titled"), + uri="resource://titled", name="titled_resource", title="User-Friendly Resource", description="Resource with title", @@ -142,9 +161,7 @@ def titled_dynamic_resource(id: str) -> str: # pragma: no cover return f"Data for {id}" # Start server and connect client - async with create_connected_server_and_client_session(mcp._mcp_server) as client: - await client.initialize() - + async with Client(mcp) as client: # List resources resources_result = await client.list_resources() resources = {str(res.uri): res for res in resources_result.resources} @@ -162,7 +179,7 @@ def titled_dynamic_resource(id: str) -> str: # pragma: no cover # List resource templates templates_result = await client.list_resource_templates() - templates = {tpl.uriTemplate: tpl for tpl in templates_result.resourceTemplates} + templates = {tpl.uri_template: tpl for tpl in templates_result.resource_templates} # Verify dynamic resource template assert "resource://dynamic/{id}" in templates @@ -181,25 +198,25 @@ async def test_get_display_name_utility(): """Test the get_display_name utility function.""" # Test tool precedence: title > annotations.title > name - tool_name_only = Tool(name="test_tool", inputSchema={}) + tool_name_only = Tool(name="test_tool", input_schema={}) assert get_display_name(tool_name_only) == "test_tool" - tool_with_title = Tool(name="test_tool", title="Test Tool", inputSchema={}) + tool_with_title = Tool(name="test_tool", title="Test Tool", input_schema={}) assert get_display_name(tool_with_title) == "Test Tool" - tool_with_annotations = Tool(name="test_tool", inputSchema={}, annotations=ToolAnnotations(title="Annotated Tool")) + tool_with_annotations = Tool(name="test_tool", input_schema={}, annotations=ToolAnnotations(title="Annotated Tool")) assert get_display_name(tool_with_annotations) == "Annotated Tool" tool_with_both = Tool( - name="test_tool", title="Primary Title", inputSchema={}, annotations=ToolAnnotations(title="Secondary Title") + name="test_tool", title="Primary Title", input_schema={}, annotations=ToolAnnotations(title="Secondary Title") ) assert get_display_name(tool_with_both) == "Primary Title" # Test other types: title > name - resource = Resource(uri=AnyUrl("file://test"), name="test_res") + resource = Resource(uri="file://test", name="test_res") assert get_display_name(resource) == "test_res" - resource_with_title = Resource(uri=AnyUrl("file://test"), name="test_res", title="Test Resource") + resource_with_title = Resource(uri="file://test", name="test_res", title="Test Resource") assert get_display_name(resource_with_title) == "Test Resource" prompt = Prompt(name="test_prompt") @@ -208,8 +225,8 @@ async def test_get_display_name_utility(): prompt_with_title = Prompt(name="test_prompt", title="Test Prompt") assert get_display_name(prompt_with_title) == "Test Prompt" - template = ResourceTemplate(uriTemplate="file://{id}", name="test_template") + template = ResourceTemplate(uri_template="file://{id}", name="test_template") assert get_display_name(template) == "test_template" - template_with_title = ResourceTemplate(uriTemplate="file://{id}", name="test_template", title="Test Template") + template_with_title = ResourceTemplate(uri_template="file://{id}", name="test_template", title="Test Template") assert get_display_name(template_with_title) == "Test Template" diff --git a/tests/server/fastmcp/test_tool_manager.py b/tests/server/mcpserver/test_tool_manager.py similarity index 93% rename from tests/server/fastmcp/test_tool_manager.py rename to tests/server/mcpserver/test_tool_manager.py index d83d48474..550bba50a 100644 --- a/tests/server/fastmcp/test_tool_manager.py +++ b/tests/server/mcpserver/test_tool_manager.py @@ -6,12 +6,12 @@ import pytest from pydantic import BaseModel -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.fastmcp.exceptions import ToolError -from mcp.server.fastmcp.tools import Tool, ToolManager -from mcp.server.fastmcp.utilities.func_metadata import ArgModelBase, FuncMetadata +from mcp.server.context import LifespanContextT, RequestT +from mcp.server.mcpserver import Context, MCPServer +from mcp.server.mcpserver.exceptions import ToolError +from mcp.server.mcpserver.tools import Tool, ToolManager +from mcp.server.mcpserver.utilities.func_metadata import ArgModelBase, FuncMetadata from mcp.server.session import ServerSessionT -from mcp.shared.context import LifespanContextT, RequestT from mcp.types import TextContent, ToolAnnotations @@ -182,7 +182,7 @@ def f(x: int) -> int: # pragma: no cover class TestCallTools: @pytest.mark.anyio async def test_call_tool(self): - def sum(a: int, b: int) -> int: # pragma: no cover + def sum(a: int, b: int) -> int: """Add two numbers.""" return a + b @@ -193,7 +193,7 @@ def sum(a: int, b: int) -> int: # pragma: no cover @pytest.mark.anyio async def test_call_async_tool(self): - async def double(n: int) -> int: # pragma: no cover + async def double(n: int) -> int: """Double a number.""" return n * 2 @@ -260,7 +260,7 @@ async def test_call_unknown_tool(self): @pytest.mark.anyio async def test_call_tool_with_list_int_input(self): - def sum_vals(vals: list[int]) -> int: # pragma: no cover + def sum_vals(vals: list[int]) -> int: return sum(vals) manager = ToolManager() @@ -273,7 +273,7 @@ def sum_vals(vals: list[int]) -> int: # pragma: no cover @pytest.mark.anyio async def test_call_tool_with_list_str_or_str_input(self): - def concat_strs(vals: list[str] | str) -> str: # pragma: no cover + def concat_strs(vals: list[str] | str) -> str: return vals if isinstance(vals, str) else "".join(vals) manager = ToolManager() @@ -297,7 +297,7 @@ class Shrimp(BaseModel): shrimp: list[Shrimp] x: None - def name_shrimp(tank: MyShrimpTank, ctx: Context[ServerSessionT, None]) -> list[str]: # pragma: no cover + def name_shrimp(tank: MyShrimpTank, ctx: Context[ServerSessionT, None]) -> list[str]: return [x.name for x in tank.shrimp] manager = ToolManager() @@ -347,9 +347,7 @@ def tool_without_context(x: int) -> str: # pragma: no cover tool = manager.add_tool(tool_without_context) assert tool.context_kwarg is None - def tool_with_parametrized_context( - x: int, ctx: Context[ServerSessionT, LifespanContextT, RequestT] - ) -> str: # pragma: no cover + def tool_with_parametrized_context(x: int, ctx: Context[LifespanContextT, RequestT]) -> str: # pragma: no cover return str(x) tool = manager.add_tool(tool_with_parametrized_context) @@ -366,7 +364,7 @@ def tool_with_context(x: int, ctx: Context[ServerSessionT, None]) -> str: manager = ToolManager() manager.add_tool(tool_with_context) - mcp = FastMCP() + mcp = MCPServer() ctx = mcp.get_context() result = await manager.call_tool("tool_with_context", {"x": 42}, context=ctx) assert result == "42" @@ -375,14 +373,14 @@ def tool_with_context(x: int, ctx: Context[ServerSessionT, None]) -> str: async def test_context_injection_async(self): """Test that context is properly injected in async tools.""" - async def async_tool(x: int, ctx: Context[ServerSessionT, None]) -> str: # pragma: no cover + async def async_tool(x: int, ctx: Context[ServerSessionT, None]) -> str: assert isinstance(ctx, Context) return str(x) manager = ToolManager() manager.add_tool(async_tool) - mcp = FastMCP() + mcp = MCPServer() ctx = mcp.get_context() result = await manager.call_tool("async_tool", {"x": 42}, context=ctx) assert result == "42" @@ -410,7 +408,7 @@ def tool_with_context(x: int, ctx: Context[ServerSessionT, None]) -> str: manager = ToolManager() manager.add_tool(tool_with_context) - mcp = FastMCP() + mcp = MCPServer() ctx = mcp.get_context() with pytest.raises(ToolError, match="Error executing tool tool_with_context"): await manager.call_tool("tool_with_context", {"x": 42}, context=ctx) @@ -426,8 +424,8 @@ def read_data(path: str) -> str: # pragma: no cover annotations = ToolAnnotations( title="File Reader", - readOnlyHint=True, - openWorldHint=False, + read_only_hint=True, + open_world_hint=False, ) manager = ToolManager() @@ -435,16 +433,16 @@ def read_data(path: str) -> str: # pragma: no cover assert tool.annotations is not None assert tool.annotations.title == "File Reader" - assert tool.annotations.readOnlyHint is True - assert tool.annotations.openWorldHint is False + assert tool.annotations.read_only_hint is True + assert tool.annotations.open_world_hint is False @pytest.mark.anyio - async def test_tool_annotations_in_fastmcp(self): + async def test_tool_annotations_in_mcpserver(self): """Test that tool annotations are included in MCPTool conversion.""" - app = FastMCP() + app = MCPServer() - @app.tool(annotations=ToolAnnotations(title="Echo Tool", readOnlyHint=True)) + @app.tool(annotations=ToolAnnotations(title="Echo Tool", read_only_hint=True)) def echo(message: str) -> str: # pragma: no cover """Echo a message back.""" return message @@ -453,7 +451,7 @@ def echo(message: str) -> str: # pragma: no cover assert len(tools) == 1 assert tools[0].annotations is not None assert tools[0].annotations.title == "Echo Tool" - assert tools[0].annotations.readOnlyHint is True + assert tools[0].annotations.read_only_hint is True class TestStructuredOutput: @@ -467,7 +465,7 @@ class UserOutput(BaseModel): name: str age: int - def get_user(user_id: int) -> UserOutput: # pragma: no cover + def get_user(user_id: int) -> UserOutput: """Get user by ID.""" return UserOutput(name="John", age=30) @@ -481,7 +479,7 @@ def get_user(user_id: int) -> UserOutput: # pragma: no cover async def test_tool_with_primitive_output(self): """Test tool with primitive return type.""" - def double_number(n: int) -> int: # pragma: no cover + def double_number(n: int) -> int: """Double a number.""" return 10 @@ -502,7 +500,7 @@ class UserDict(TypedDict): expected_output = {"name": "Alice", "age": 25} - def get_user_dict(user_id: int) -> UserDict: # pragma: no cover + def get_user_dict(user_id: int) -> UserDict: """Get user as dict.""" return UserDict(name="Alice", age=25) @@ -522,7 +520,7 @@ class Person: expected_output = {"name": "Bob", "age": 40} - def get_person() -> Person: # pragma: no cover + def get_person() -> Person: """Get a person.""" return Person("Bob", 40) @@ -539,7 +537,7 @@ async def test_tool_with_list_output(self): expected_list = [1, 2, 3, 4, 5] expected_output = {"result": expected_list} - def get_numbers() -> list[int]: # pragma: no cover + def get_numbers() -> list[int]: """Get a list of numbers.""" return expected_list @@ -590,7 +588,7 @@ def get_user() -> UserOutput: # pragma: no cover async def test_tool_with_dict_str_any_output(self): """Test tool with dict[str, Any] return type.""" - def get_config() -> dict[str, Any]: # pragma: no cover + def get_config() -> dict[str, Any]: """Get configuration""" return {"debug": True, "port": 8080, "features": ["auth", "logging"]} @@ -615,7 +613,7 @@ def get_config() -> dict[str, Any]: # pragma: no cover async def test_tool_with_dict_str_typed_output(self): """Test tool with dict[str, T] return type for specific T.""" - def get_scores() -> dict[str, int]: # pragma: no cover + def get_scores() -> dict[str, int]: """Get player scores""" return {"alice": 100, "bob": 85, "charlie": 92} @@ -670,10 +668,10 @@ def simple_tool(x: int) -> int: # pragma: no cover assert tool.meta is None @pytest.mark.anyio - async def test_metadata_in_fastmcp_decorator(self): - """Test that metadata is correctly added via FastMCP.tool decorator.""" + async def test_metadata_in_mcpserver_decorator(self): + """Test that metadata is correctly added via MCPServer.tool decorator.""" - app = FastMCP() + app = MCPServer() metadata = {"client": {"ui_component": "file_picker"}, "priority": "high"} @@ -694,7 +692,7 @@ def upload_file(filename: str) -> str: # pragma: no cover async def test_metadata_in_list_tools(self): """Test that metadata is included in MCPTool when listing tools.""" - app = FastMCP() + app = MCPServer() metadata = { "ui": {"input_type": "textarea", "rows": 5}, @@ -715,7 +713,7 @@ def analyze_text(text: str) -> dict[str, Any]: # pragma: no cover async def test_multiple_tools_with_different_metadata(self): """Test multiple tools with different metadata values.""" - app = FastMCP() + app = MCPServer() metadata1 = {"ui": "form", "version": 1} metadata2 = {"ui": "picker", "experimental": True} @@ -791,10 +789,10 @@ def tool_with_empty_meta(x: int) -> int: # pragma: no cover async def test_metadata_with_annotations(self): """Test that metadata and annotations can coexist.""" - app = FastMCP() + app = MCPServer() metadata = {"custom": "value"} - annotations = ToolAnnotations(title="Combined Tool", readOnlyHint=True) + annotations = ToolAnnotations(title="Combined Tool", read_only_hint=True) @app.tool(meta=metadata, annotations=annotations) def combined_tool(data: str) -> str: # pragma: no cover @@ -806,7 +804,7 @@ def combined_tool(data: str) -> str: # pragma: no cover assert tools[0].meta == metadata assert tools[0].annotations is not None assert tools[0].annotations.title == "Combined Tool" - assert tools[0].annotations.readOnlyHint is True + assert tools[0].annotations.read_only_hint is True class TestRemoveTools: @@ -879,7 +877,7 @@ def divide(a: int, b: int) -> float: # pragma: no cover async def test_call_removed_tool_raises_error(self): """Test that calling a removed tool raises ToolError.""" - def greet(name: str) -> str: # pragma: no cover + def greet(name: str) -> str: """Greet someone.""" return f"Hello, {name}!" diff --git a/tests/server/fastmcp/test_url_elicitation.py b/tests/server/mcpserver/test_url_elicitation.py similarity index 70% rename from tests/server/fastmcp/test_url_elicitation.py rename to tests/server/mcpserver/test_url_elicitation.py index a4d3b2e64..1311bd672 100644 --- a/tests/server/fastmcp/test_url_elicitation.py +++ b/tests/server/mcpserver/test_url_elicitation.py @@ -2,21 +2,21 @@ import anyio import pytest +from pydantic import BaseModel, Field -from mcp import types +from mcp import Client, types from mcp.client.session import ClientSession -from mcp.server.elicitation import CancelledElicitation, DeclinedElicitation -from mcp.server.fastmcp import Context, FastMCP +from mcp.server.elicitation import CancelledElicitation, DeclinedElicitation, elicit_url +from mcp.server.mcpserver import Context, MCPServer from mcp.server.session import ServerSession -from mcp.shared.context import RequestContext -from mcp.shared.memory import create_connected_server_and_client_session +from mcp.shared._context import RequestContext from mcp.types import ElicitRequestParams, ElicitResult, TextContent @pytest.mark.anyio async def test_url_elicitation_accept(): """Test URL mode elicitation with user acceptance.""" - mcp = FastMCP(name="URLElicitationServer") + mcp = MCPServer(name="URLElicitationServer") @mcp.tool(description="A tool that uses URL elicitation") async def request_api_key(ctx: Context[ServerSession, None]) -> str: @@ -29,19 +29,15 @@ async def request_api_key(ctx: Context[ServerSession, None]) -> str: return f"User {result.action}" # Create elicitation callback that accepts URL mode - async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams): assert params.mode == "url" assert params.url == "https://example.com/api_key_setup" - assert params.elicitationId == "test-elicitation-001" + assert params.elicitation_id == "test-elicitation-001" assert params.message == "Please provide your API key to continue." return ElicitResult(action="accept") - async with create_connected_server_and_client_session( - mcp._mcp_server, elicitation_callback=elicitation_callback - ) as client_session: - await client_session.initialize() - - result = await client_session.call_tool("request_api_key", {}) + async with Client(mcp, elicitation_callback=elicitation_callback) as client: + result = await client.call_tool("request_api_key", {}) assert len(result.content) == 1 assert isinstance(result.content[0], TextContent) assert result.content[0].text == "User accept" @@ -50,7 +46,7 @@ async def elicitation_callback(context: RequestContext[ClientSession, None], par @pytest.mark.anyio async def test_url_elicitation_decline(): """Test URL mode elicitation with user declining.""" - mcp = FastMCP(name="URLElicitationDeclineServer") + mcp = MCPServer(name="URLElicitationDeclineServer") @mcp.tool(description="A tool that uses URL elicitation") async def oauth_flow(ctx: Context[ServerSession, None]) -> str: @@ -62,16 +58,12 @@ async def oauth_flow(ctx: Context[ServerSession, None]) -> str: # Test only checks decline path return f"User {result.action} authorization" - async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams): assert params.mode == "url" return ElicitResult(action="decline") - async with create_connected_server_and_client_session( - mcp._mcp_server, elicitation_callback=elicitation_callback - ) as client_session: - await client_session.initialize() - - result = await client_session.call_tool("oauth_flow", {}) + async with Client(mcp, elicitation_callback=elicitation_callback) as client: + result = await client.call_tool("oauth_flow", {}) assert len(result.content) == 1 assert isinstance(result.content[0], TextContent) assert result.content[0].text == "User decline authorization" @@ -80,7 +72,7 @@ async def elicitation_callback(context: RequestContext[ClientSession, None], par @pytest.mark.anyio async def test_url_elicitation_cancel(): """Test URL mode elicitation with user cancelling.""" - mcp = FastMCP(name="URLElicitationCancelServer") + mcp = MCPServer(name="URLElicitationCancelServer") @mcp.tool(description="A tool that uses URL elicitation") async def payment_flow(ctx: Context[ServerSession, None]) -> str: @@ -92,16 +84,12 @@ async def payment_flow(ctx: Context[ServerSession, None]) -> str: # Test only checks cancel path return f"User {result.action} payment" - async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams): assert params.mode == "url" return ElicitResult(action="cancel") - async with create_connected_server_and_client_session( - mcp._mcp_server, elicitation_callback=elicitation_callback - ) as client_session: - await client_session.initialize() - - result = await client_session.call_tool("payment_flow", {}) + async with Client(mcp, elicitation_callback=elicitation_callback) as client: + result = await client.call_tool("payment_flow", {}) assert len(result.content) == 1 assert isinstance(result.content[0], TextContent) assert result.content[0].text == "User cancel payment" @@ -110,9 +98,7 @@ async def elicitation_callback(context: RequestContext[ClientSession, None], par @pytest.mark.anyio async def test_url_elicitation_helper_function(): """Test the elicit_url helper function.""" - from mcp.server.elicitation import elicit_url - - mcp = FastMCP(name="URLElicitationHelperServer") + mcp = MCPServer(name="URLElicitationHelperServer") @mcp.tool(description="Tool using elicit_url helper") async def setup_credentials(ctx: Context[ServerSession, None]) -> str: @@ -125,15 +111,11 @@ async def setup_credentials(ctx: Context[ServerSession, None]) -> str: # Test only checks accept path - return the type name return type(result).__name__ - async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams): return ElicitResult(action="accept") - async with create_connected_server_and_client_session( - mcp._mcp_server, elicitation_callback=elicitation_callback - ) as client_session: - await client_session.initialize() - - result = await client_session.call_tool("setup_credentials", {}) + async with Client(mcp, elicitation_callback=elicitation_callback) as client: + result = await client.call_tool("setup_credentials", {}) assert len(result.content) == 1 assert isinstance(result.content[0], TextContent) assert result.content[0].text == "AcceptedUrlElicitation" @@ -142,7 +124,7 @@ async def elicitation_callback(context: RequestContext[ClientSession, None], par @pytest.mark.anyio async def test_url_no_content_in_response(): """Test that URL mode elicitation responses don't include content field.""" - mcp = FastMCP(name="URLContentCheckServer") + mcp = MCPServer(name="URLContentCheckServer") @mcp.tool(description="Check URL response format") async def check_url_response(ctx: Context[ServerSession, None]) -> str: @@ -156,22 +138,18 @@ async def check_url_response(ctx: Context[ServerSession, None]) -> str: assert result.content is None return f"Action: {result.action}, Content: {result.content}" - async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams): # Verify that this is URL mode assert params.mode == "url" assert isinstance(params, types.ElicitRequestURLParams) - # URL params have url and elicitationId, not requestedSchema + # URL params have url and elicitation_id, not requested_schema assert params.url == "https://example.com/test" - assert params.elicitationId == "test-001" + assert params.elicitation_id == "test-001" # Return without content - this is correct for URL mode return ElicitResult(action="accept") - async with create_connected_server_and_client_session( - mcp._mcp_server, elicitation_callback=elicitation_callback - ) as client_session: - await client_session.initialize() - - result = await client_session.call_tool("check_url_response", {}) + async with Client(mcp, elicitation_callback=elicitation_callback) as client: + result = await client.call_tool("check_url_response", {}) assert len(result.content) == 1 assert isinstance(result.content[0], TextContent) assert "Content: None" in result.content[0].text @@ -180,9 +158,7 @@ async def elicitation_callback(context: RequestContext[ClientSession, None], par @pytest.mark.anyio async def test_form_mode_still_works(): """Ensure form mode elicitation still works after SEP 1036.""" - from pydantic import BaseModel, Field - - mcp = FastMCP(name="FormModeBackwardCompatServer") + mcp = MCPServer(name="FormModeBackwardCompatServer") class NameSchema(BaseModel): name: str = Field(description="Your name") @@ -195,20 +171,16 @@ async def ask_name(ctx: Context[ServerSession, None]) -> str: assert result.data is not None return f"Hello, {result.data.name}!" - async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams): # Verify form mode parameters assert params.mode == "form" assert isinstance(params, types.ElicitRequestFormParams) - # Form params have requestedSchema, not url/elicitationId - assert params.requestedSchema is not None + # Form params have requested_schema, not url/elicitation_id + assert params.requested_schema is not None return ElicitResult(action="accept", content={"name": "Alice"}) - async with create_connected_server_and_client_session( - mcp._mcp_server, elicitation_callback=elicitation_callback - ) as client_session: - await client_session.initialize() - - result = await client_session.call_tool("ask_name", {}) + async with Client(mcp, elicitation_callback=elicitation_callback) as client: + result = await client.call_tool("ask_name", {}) assert len(result.content) == 1 assert isinstance(result.content[0], TextContent) assert result.content[0].text == "Hello, Alice!" @@ -217,7 +189,7 @@ async def elicitation_callback(context: RequestContext[ClientSession, None], par @pytest.mark.anyio async def test_elicit_complete_notification(): """Test that elicitation completion notifications can be sent and received.""" - mcp = FastMCP(name="ElicitCompleteServer") + mcp = MCPServer(name="ElicitCompleteServer") # Track if the notification was sent notification_sent = False @@ -235,15 +207,11 @@ async def trigger_elicitation(ctx: Context[ServerSession, None]) -> str: return "Elicitation completed" - async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams): return ElicitResult(action="accept") # pragma: no cover - async with create_connected_server_and_client_session( - mcp._mcp_server, elicitation_callback=elicitation_callback - ) as client_session: - await client_session.initialize() - - result = await client_session.call_tool("trigger_elicitation", {}) + async with Client(mcp, elicitation_callback=elicitation_callback) as client: + result = await client.call_tool("trigger_elicitation", {}) assert len(result.content) == 1 assert isinstance(result.content[0], TextContent) assert result.content[0].text == "Elicitation completed" @@ -267,9 +235,7 @@ async def test_url_elicitation_required_error_code(): @pytest.mark.anyio async def test_elicit_url_typed_results(): """Test that elicit_url returns properly typed result objects.""" - from mcp.server.elicitation import elicit_url - - mcp = FastMCP(name="TypedResultsServer") + mcp = MCPServer(name="TypedResultsServer") @mcp.tool(description="Test declined result") async def test_decline(ctx: Context[ServerSession, None]) -> str: @@ -298,29 +264,21 @@ async def test_cancel(ctx: Context[ServerSession, None]) -> str: return "Not cancelled" # pragma: no cover # Test declined result - async def decline_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + async def decline_callback(context: RequestContext[ClientSession], params: ElicitRequestParams): return ElicitResult(action="decline") - async with create_connected_server_and_client_session( - mcp._mcp_server, elicitation_callback=decline_callback - ) as client_session: - await client_session.initialize() - - result = await client_session.call_tool("test_decline", {}) + async with Client(mcp, elicitation_callback=decline_callback) as client: + result = await client.call_tool("test_decline", {}) assert len(result.content) == 1 assert isinstance(result.content[0], TextContent) assert result.content[0].text == "Declined" # Test cancelled result - async def cancel_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + async def cancel_callback(context: RequestContext[ClientSession], params: ElicitRequestParams): return ElicitResult(action="cancel") - async with create_connected_server_and_client_session( - mcp._mcp_server, elicitation_callback=cancel_callback - ) as client_session: - await client_session.initialize() - - result = await client_session.call_tool("test_cancel", {}) + async with Client(mcp, elicitation_callback=cancel_callback) as client: + result = await client.call_tool("test_cancel", {}) assert len(result.content) == 1 assert isinstance(result.content[0], TextContent) assert result.content[0].text == "Cancelled" @@ -329,9 +287,7 @@ async def cancel_callback(context: RequestContext[ClientSession, None], params: @pytest.mark.anyio async def test_deprecated_elicit_method(): """Test the deprecated elicit() method for backward compatibility.""" - from pydantic import BaseModel, Field - - mcp = FastMCP(name="DeprecatedElicitServer") + mcp = MCPServer(name="DeprecatedElicitServer") class EmailSchema(BaseModel): email: str = Field(description="Email address") @@ -341,25 +297,21 @@ async def use_deprecated_elicit(ctx: Context[ServerSession, None]) -> str: # Use the deprecated elicit() method which should call elicit_form() result = await ctx.session.elicit( message="Enter your email", - requestedSchema=EmailSchema.model_json_schema(), + requested_schema=EmailSchema.model_json_schema(), ) if result.action == "accept" and result.content: return f"Email: {result.content.get('email', 'none')}" return "No email provided" # pragma: no cover - async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams): # Verify this is form mode assert params.mode == "form" - assert params.requestedSchema is not None + assert params.requested_schema is not None return ElicitResult(action="accept", content={"email": "test@example.com"}) - async with create_connected_server_and_client_session( - mcp._mcp_server, elicitation_callback=elicitation_callback - ) as client_session: - await client_session.initialize() - - result = await client_session.call_tool("use_deprecated_elicit", {}) + async with Client(mcp, elicitation_callback=elicitation_callback) as client: + result = await client.call_tool("use_deprecated_elicit", {}) assert len(result.content) == 1 assert isinstance(result.content[0], TextContent) assert result.content[0].text == "Email: test@example.com" @@ -368,7 +320,7 @@ async def elicitation_callback(context: RequestContext[ClientSession, None], par @pytest.mark.anyio async def test_ctx_elicit_url_convenience_method(): """Test the ctx.elicit_url() convenience method (vs ctx.session.elicit_url()).""" - mcp = FastMCP(name="CtxElicitUrlServer") + mcp = MCPServer(name="CtxElicitUrlServer") @mcp.tool(description="A tool that uses ctx.elicit_url() directly") async def direct_elicit_url(ctx: Context[ServerSession, None]) -> str: @@ -380,15 +332,12 @@ async def direct_elicit_url(ctx: Context[ServerSession, None]) -> str: ) return f"Result: {result.action}" - async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams): + async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams): assert params.mode == "url" - assert params.elicitationId == "ctx-test-001" + assert params.elicitation_id == "ctx-test-001" return ElicitResult(action="accept") - async with create_connected_server_and_client_session( - mcp._mcp_server, elicitation_callback=elicitation_callback - ) as client_session: - await client_session.initialize() - result = await client_session.call_tool("direct_elicit_url", {}) + async with Client(mcp, elicitation_callback=elicitation_callback) as client: + result = await client.call_tool("direct_elicit_url", {}) assert isinstance(result.content[0], TextContent) assert result.content[0].text == "Result: accept" diff --git a/tests/server/mcpserver/test_url_elicitation_error_throw.py b/tests/server/mcpserver/test_url_elicitation_error_throw.py new file mode 100644 index 000000000..2d2993799 --- /dev/null +++ b/tests/server/mcpserver/test_url_elicitation_error_throw.py @@ -0,0 +1,109 @@ +"""Test that UrlElicitationRequiredError is properly propagated as MCP error.""" + +import pytest +from inline_snapshot import snapshot + +from mcp import Client, ErrorData, types +from mcp.server.mcpserver import Context, MCPServer +from mcp.server.session import ServerSession +from mcp.shared.exceptions import MCPError, UrlElicitationRequiredError + + +@pytest.mark.anyio +async def test_url_elicitation_error_thrown_from_tool(): + """Test that UrlElicitationRequiredError raised from a tool is received as MCPError by client.""" + mcp = MCPServer(name="UrlElicitationErrorServer") + + @mcp.tool(description="A tool that raises UrlElicitationRequiredError") + async def connect_service(service_name: str, ctx: Context[ServerSession, None]) -> str: + # This tool cannot proceed without authorization + raise UrlElicitationRequiredError( + [ + types.ElicitRequestURLParams( + mode="url", + message=f"Authorization required to connect to {service_name}", + url=f"https://{service_name}.example.com/oauth/authorize", + elicitation_id=f"{service_name}-auth-001", + ) + ] + ) + + async with Client(mcp) as client: + with pytest.raises(MCPError) as exc_info: + await client.call_tool("connect_service", {"service_name": "github"}) + + assert exc_info.value.error == snapshot( + ErrorData( + code=types.URL_ELICITATION_REQUIRED, + message="URL elicitation required", + data={ + "elicitations": [ + { + "mode": "url", + "message": "Authorization required to connect to github", + "url": "https://github.example.com/oauth/authorize", + "elicitationId": "github-auth-001", + } + ] + }, + ) + ) + + +@pytest.mark.anyio +async def test_url_elicitation_error_from_error(): + """Test that client can reconstruct UrlElicitationRequiredError from MCPError.""" + mcp = MCPServer(name="UrlElicitationErrorServer") + + @mcp.tool(description="A tool that raises UrlElicitationRequiredError with multiple elicitations") + async def multi_auth(ctx: Context[ServerSession, None]) -> str: + raise UrlElicitationRequiredError( + [ + types.ElicitRequestURLParams( + mode="url", + message="GitHub authorization required", + url="https://github.example.com/oauth", + elicitation_id="github-auth", + ), + types.ElicitRequestURLParams( + mode="url", + message="Google Drive authorization required", + url="https://drive.google.com/oauth", + elicitation_id="gdrive-auth", + ), + ] + ) + + async with Client(mcp) as client: + # Call the tool and catch the error + with pytest.raises(MCPError) as exc_info: + await client.call_tool("multi_auth", {}) + + # Reconstruct the typed error + mcp_error = exc_info.value + assert mcp_error.code == types.URL_ELICITATION_REQUIRED + + url_error = UrlElicitationRequiredError.from_error(mcp_error.error) + + # Verify the reconstructed error has both elicitations + assert len(url_error.elicitations) == 2 + assert url_error.elicitations[0].elicitation_id == "github-auth" + assert url_error.elicitations[1].elicitation_id == "gdrive-auth" + + +@pytest.mark.anyio +async def test_normal_exceptions_still_return_error_result(): + """Test that normal exceptions still return CallToolResult with is_error=True.""" + mcp = MCPServer(name="NormalErrorServer") + + @mcp.tool(description="A tool that raises a normal exception") + async def failing_tool(ctx: Context[ServerSession, None]) -> str: + raise ValueError("Something went wrong") + + async with Client(mcp) as client: + # Normal exceptions should be returned as error results, not MCPError + result = await client.call_tool("failing_tool", {}) + assert result.is_error is True + assert len(result.content) == 1 + assert isinstance(result.content[0], types.TextContent) + assert "Something went wrong" in result.content[0].text diff --git a/tests/server/test_cancel_handling.py b/tests/server/test_cancel_handling.py index 47c49bb62..297f3d6a5 100644 --- a/tests/server/test_cancel_handling.py +++ b/tests/server/test_cancel_handling.py @@ -1,22 +1,20 @@ """Test that cancelled requests don't cause double responses.""" -from typing import Any - import anyio import pytest -import mcp.types as types -from mcp.server.lowlevel.server import Server -from mcp.shared.exceptions import McpError -from mcp.shared.memory import create_connected_server_and_client_session +from mcp import Client +from mcp.server import Server, ServerRequestContext +from mcp.shared.exceptions import MCPError from mcp.types import ( CallToolRequest, CallToolRequestParams, CallToolResult, CancelledNotification, CancelledNotificationParams, - ClientNotification, - ClientRequest, + ListToolsResult, + PaginatedRequestParams, + TextContent, Tool, ) @@ -25,49 +23,45 @@ async def test_server_remains_functional_after_cancel(): """Verify server can handle new requests after a cancellation.""" - server = Server("test-server") - # Track tool calls call_count = 0 ev_first_call = anyio.Event() first_request_id = None - @server.list_tools() - async def handle_list_tools() -> list[Tool]: - return [ - Tool( - name="test_tool", - description="Tool for testing", - inputSchema={}, - ) - ] + async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult( + tools=[ + Tool( + name="test_tool", + description="Tool for testing", + input_schema={}, + ) + ] + ) - @server.call_tool() - async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[types.TextContent]: + async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: nonlocal call_count, first_request_id - if name == "test_tool": + if params.name == "test_tool": call_count += 1 if call_count == 1: - first_request_id = server.request_context.request_id + first_request_id = ctx.request_id ev_first_call.set() await anyio.sleep(5) # First call is slow - return [types.TextContent(type="text", text=f"Call number: {call_count}")] - raise ValueError(f"Unknown tool: {name}") # pragma: no cover + return CallToolResult(content=[TextContent(type="text", text=f"Call number: {call_count}")]) + raise ValueError(f"Unknown tool: {params.name}") # pragma: no cover + + server = Server("test-server", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool) - async with create_connected_server_and_client_session(server) as client: + async with Client(server) as client: # First request (will be cancelled) async def first_request(): try: - await client.send_request( - ClientRequest( - CallToolRequest( - params=CallToolRequestParams(name="test_tool", arguments={}), - ) - ), + await client.session.send_request( + CallToolRequest(params=CallToolRequestParams(name="test_tool", arguments={})), CallToolResult, ) pytest.fail("First request should have been cancelled") # pragma: no cover - except McpError: + except MCPError: pass # Expected # Start first request @@ -79,32 +73,20 @@ async def first_request(): # Cancel it assert first_request_id is not None - await client.send_notification( - ClientNotification( - CancelledNotification( - params=CancelledNotificationParams( - requestId=first_request_id, - reason="Testing server recovery", - ), - ) + await client.session.send_notification( + CancelledNotification( + params=CancelledNotificationParams(request_id=first_request_id, reason="Testing server recovery"), ) ) # Second request (should work normally) - result = await client.send_request( - ClientRequest( - CallToolRequest( - params=CallToolRequestParams(name="test_tool", arguments={}), - ) - ), - CallToolResult, - ) + result = await client.call_tool("test_tool", {}) # Verify second request completed successfully assert len(result.content) == 1 # Type narrowing for pyright content = result.content[0] assert content.type == "text" - assert isinstance(content, types.TextContent) + assert isinstance(content, TextContent) assert content.text == "Call number: 2" assert call_count == 2 diff --git a/tests/server/test_completion_with_context.py b/tests/server/test_completion_with_context.py index eb9604791..a01d0d4d7 100644 --- a/tests/server/test_completion_with_context.py +++ b/tests/server/test_completion_with_context.py @@ -1,17 +1,13 @@ -""" -Tests for completion handler with context functionality. -""" - -from typing import Any +"""Tests for completion handler with context functionality.""" import pytest -from mcp.server.lowlevel import Server -from mcp.shared.memory import create_connected_server_and_client_session +from mcp import Client +from mcp.server import Server, ServerRequestContext from mcp.types import ( + CompleteRequestParams, + CompleteResult, Completion, - CompletionArgument, - CompletionContext, PromptReference, ResourceTemplateReference, ) @@ -20,25 +16,17 @@ @pytest.mark.anyio async def test_completion_handler_receives_context(): """Test that the completion handler receives context correctly.""" - server = Server("test-server") - # Track what the handler receives - received_args: dict[str, Any] = {} - - @server.completion() - async def handle_completion( - ref: PromptReference | ResourceTemplateReference, - argument: CompletionArgument, - context: CompletionContext | None, - ) -> Completion | None: - received_args["ref"] = ref - received_args["argument"] = argument - received_args["context"] = context - - # Return test completion - return Completion(values=["test-completion"], total=1, hasMore=False) - - async with create_connected_server_and_client_session(server) as client: + received_params: CompleteRequestParams | None = None + + async def handle_completion(ctx: ServerRequestContext, params: CompleteRequestParams) -> CompleteResult: + nonlocal received_params + received_params = params + return CompleteResult(completion=Completion(values=["test-completion"], total=1, has_more=False)) + + server = Server("test-server", on_completion=handle_completion) + + async with Client(server) as client: # Test with context result = await client.complete( ref=ResourceTemplateReference(type="ref/resource", uri="test://resource/{param}"), @@ -47,30 +35,25 @@ async def handle_completion( ) # Verify handler received the context - assert received_args["context"] is not None - assert received_args["context"].arguments == {"previous": "value"} + assert received_params is not None + assert received_params.context is not None + assert received_params.context.arguments == {"previous": "value"} assert result.completion.values == ["test-completion"] @pytest.mark.anyio async def test_completion_backward_compatibility(): """Test that completion works without context (backward compatibility).""" - server = Server("test-server") - context_was_none = False - @server.completion() - async def handle_completion( - ref: PromptReference | ResourceTemplateReference, - argument: CompletionArgument, - context: CompletionContext | None, - ) -> Completion | None: + async def handle_completion(ctx: ServerRequestContext, params: CompleteRequestParams) -> CompleteResult: nonlocal context_was_none - context_was_none = context is None + context_was_none = params.context is None + return CompleteResult(completion=Completion(values=["no-context-completion"], total=1, has_more=False)) - return Completion(values=["no-context-completion"], total=1, hasMore=False) + server = Server("test-server", on_completion=handle_completion) - async with create_connected_server_and_client_session(server) as client: + async with Client(server) as client: # Test without context result = await client.complete( ref=PromptReference(type="ref/prompt", name="test-prompt"), argument={"name": "arg", "value": "val"} @@ -84,32 +67,33 @@ async def handle_completion( @pytest.mark.anyio async def test_dependent_completion_scenario(): """Test a real-world scenario with dependent completions.""" - server = Server("test-server") - - @server.completion() - async def handle_completion( - ref: PromptReference | ResourceTemplateReference, - argument: CompletionArgument, - context: CompletionContext | None, - ) -> Completion | None: + + async def handle_completion(ctx: ServerRequestContext, params: CompleteRequestParams) -> CompleteResult: # Simulate database/table completion scenario - if isinstance(ref, ResourceTemplateReference): - if ref.uri == "db://{database}/{table}": - if argument.name == "database": - # Complete database names - return Completion(values=["users_db", "products_db", "analytics_db"], total=3, hasMore=False) - elif argument.name == "table": - # Complete table names based on selected database - if context and context.arguments: - db = context.arguments.get("database") - if db == "users_db": - return Completion(values=["users", "sessions", "permissions"], total=3, hasMore=False) - elif db == "products_db": # pragma: no cover - return Completion(values=["products", "categories", "inventory"], total=3, hasMore=False) - - return Completion(values=[], total=0, hasMore=False) # pragma: no cover - - async with create_connected_server_and_client_session(server) as client: + assert isinstance(params.ref, ResourceTemplateReference) + assert params.ref.uri == "db://{database}/{table}" + + if params.argument.name == "database": + return CompleteResult( + completion=Completion(values=["users_db", "products_db", "analytics_db"], total=3, has_more=False) + ) + + assert params.argument.name == "table" + assert params.context and params.context.arguments + db = params.context.arguments.get("database") + if db == "users_db": + return CompleteResult( + completion=Completion(values=["users", "sessions", "permissions"], total=3, has_more=False) + ) + else: + assert db == "products_db" + return CompleteResult( + completion=Completion(values=["products", "categories", "inventory"], total=3, has_more=False) + ) + + server = Server("test-server", on_completion=handle_completion) + + async with Client(server) as client: # First, complete database db_result = await client.complete( ref=ResourceTemplateReference(type="ref/resource", uri="db://{database}/{table}"), @@ -138,29 +122,22 @@ async def handle_completion( @pytest.mark.anyio async def test_completion_error_on_missing_context(): """Test that server can raise error when required context is missing.""" - server = Server("test-server") - - @server.completion() - async def handle_completion( - ref: PromptReference | ResourceTemplateReference, - argument: CompletionArgument, - context: CompletionContext | None, - ) -> Completion | None: - if isinstance(ref, ResourceTemplateReference): - if ref.uri == "db://{database}/{table}": - if argument.name == "table": - # Check if database context is provided - if not context or not context.arguments or "database" not in context.arguments: - # Raise an error instead of returning error as completion - raise ValueError("Please select a database first to see available tables") - # Normal completion if context is provided - db = context.arguments.get("database") - if db == "test_db": # pragma: no cover - return Completion(values=["users", "orders", "products"], total=3, hasMore=False) - - return Completion(values=[], total=0, hasMore=False) # pragma: no cover - - async with create_connected_server_and_client_session(server) as client: + + async def handle_completion(ctx: ServerRequestContext, params: CompleteRequestParams) -> CompleteResult: + assert isinstance(params.ref, ResourceTemplateReference) + assert params.ref.uri == "db://{database}/{table}" + assert params.argument.name == "table" + + if not params.context or not params.context.arguments or "database" not in params.context.arguments: + raise ValueError("Please select a database first to see available tables") + + db = params.context.arguments.get("database") + assert db == "test_db" + return CompleteResult(completion=Completion(values=["users", "orders", "products"], total=3, has_more=False)) + + server = Server("test-server", on_completion=handle_completion) + + async with Client(server) as client: # Try to complete table without database context - should raise error with pytest.raises(Exception) as exc_info: await client.complete( diff --git a/tests/server/test_lifespan.py b/tests/server/test_lifespan.py index 9d73fd47a..0f8840d29 100644 --- a/tests/server/test_lifespan.py +++ b/tests/server/test_lifespan.py @@ -1,19 +1,21 @@ -"""Tests for lifespan functionality in both low-level and FastMCP servers.""" +"""Tests for lifespan functionality in both low-level and MCPServer servers.""" from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from typing import Any import anyio import pytest from pydantic import TypeAdapter -from mcp.server.fastmcp import Context, FastMCP +from mcp.server import ServerRequestContext from mcp.server.lowlevel.server import NotificationOptions, Server +from mcp.server.mcpserver import Context, MCPServer from mcp.server.models import InitializationOptions from mcp.server.session import ServerSession from mcp.shared.message import SessionMessage from mcp.types import ( + CallToolRequestParams, + CallToolResult, ClientCapabilities, Implementation, InitializeRequestParams, @@ -39,20 +41,20 @@ async def test_lifespan(server: Server) -> AsyncIterator[dict[str, bool]]: finally: context["shutdown"] = True - server = Server[dict[str, bool]]("test", lifespan=test_lifespan) - - # Create memory streams for testing - send_stream1, receive_stream1 = anyio.create_memory_object_stream[SessionMessage](100) - send_stream2, receive_stream2 = anyio.create_memory_object_stream[SessionMessage](100) - # Create a tool that accesses lifespan context - @server.call_tool() - async def check_lifespan(name: str, arguments: dict[str, Any]) -> list[TextContent]: - ctx = server.request_context + async def check_lifespan( + ctx: ServerRequestContext[dict[str, bool]], params: CallToolRequestParams + ) -> CallToolResult: assert isinstance(ctx.lifespan_context, dict) assert ctx.lifespan_context["started"] assert not ctx.lifespan_context["shutdown"] - return [TextContent(type="text", text="true")] + return CallToolResult(content=[TextContent(type="text", text="true")]) + + server = Server[dict[str, bool]]("test", lifespan=test_lifespan, on_call_tool=check_lifespan) + + # Create memory streams for testing + send_stream1, receive_stream1 = anyio.create_memory_object_stream[SessionMessage](100) + send_stream2, receive_stream2 = anyio.create_memory_object_stream[SessionMessage](100) # Run server in background task async with anyio.create_task_group() as tg, send_stream1, receive_stream1, send_stream2, receive_stream2: @@ -76,19 +78,17 @@ async def run_server(): # Initialize the server params = InitializeRequestParams( - protocolVersion="2024-11-05", + protocol_version="2024-11-05", capabilities=ClientCapabilities(), - clientInfo=Implementation(name="test-client", version="0.1.0"), + client_info=Implementation(name="test-client", version="0.1.0"), ) await send_stream1.send( SessionMessage( - JSONRPCMessage( - root=JSONRPCRequest( - jsonrpc="2.0", - id=1, - method="initialize", - params=TypeAdapter(InitializeRequestParams).dump_python(params), - ) + JSONRPCRequest( + jsonrpc="2.0", + id=1, + method="initialize", + params=TypeAdapter(InitializeRequestParams).dump_python(params), ) ) ) @@ -96,27 +96,16 @@ async def run_server(): response = response.message # Send initialized notification - await send_stream1.send( - SessionMessage( - JSONRPCMessage( - root=JSONRPCNotification( - jsonrpc="2.0", - method="notifications/initialized", - ) - ) - ) - ) + await send_stream1.send(SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized"))) # Call the tool to verify lifespan context await send_stream1.send( SessionMessage( - JSONRPCMessage( - root=JSONRPCRequest( - jsonrpc="2.0", - id=2, - method="tools/call", - params={"name": "check_lifespan", "arguments": {}}, - ) + JSONRPCRequest( + jsonrpc="2.0", + id=2, + method="tools/call", + params={"name": "check_lifespan", "arguments": {}}, ) ) ) @@ -125,19 +114,19 @@ async def run_server(): response = await receive_stream2.receive() response = response.message assert isinstance(response, JSONRPCMessage) - assert isinstance(response.root, JSONRPCResponse) - assert response.root.result["content"][0]["text"] == "true" + assert isinstance(response, JSONRPCResponse) + assert response.result["content"][0]["text"] == "true" # Cancel server task tg.cancel_scope.cancel() @pytest.mark.anyio -async def test_fastmcp_server_lifespan(): - """Test that lifespan works in FastMCP server.""" +async def test_mcpserver_server_lifespan(): + """Test that lifespan works in MCPServer server.""" @asynccontextmanager - async def test_lifespan(server: FastMCP) -> AsyncIterator[dict[str, bool]]: + async def test_lifespan(server: MCPServer) -> AsyncIterator[dict[str, bool]]: """Test lifespan context that tracks startup/shutdown.""" context = {"started": False, "shutdown": False} try: @@ -146,7 +135,7 @@ async def test_lifespan(server: FastMCP) -> AsyncIterator[dict[str, bool]]: finally: context["shutdown"] = True - server = FastMCP("test", lifespan=test_lifespan) + server = MCPServer("test", lifespan=test_lifespan) # Create memory streams for testing send_stream1, receive_stream1 = anyio.create_memory_object_stream[SessionMessage](100) @@ -162,19 +151,13 @@ def check_lifespan(ctx: Context[ServerSession, None]) -> bool: return True # Run server in background task - async with ( - anyio.create_task_group() as tg, - send_stream1, - receive_stream1, - send_stream2, - receive_stream2, - ): + async with anyio.create_task_group() as tg, send_stream1, receive_stream1, send_stream2, receive_stream2: async def run_server(): - await server._mcp_server.run( + await server._lowlevel_server.run( receive_stream1, send_stream2, - server._mcp_server.create_initialization_options(), + server._lowlevel_server.create_initialization_options(), raise_exceptions=True, ) @@ -182,19 +165,17 @@ async def run_server(): # Initialize the server params = InitializeRequestParams( - protocolVersion="2024-11-05", + protocol_version="2024-11-05", capabilities=ClientCapabilities(), - clientInfo=Implementation(name="test-client", version="0.1.0"), + client_info=Implementation(name="test-client", version="0.1.0"), ) await send_stream1.send( SessionMessage( - JSONRPCMessage( - root=JSONRPCRequest( - jsonrpc="2.0", - id=1, - method="initialize", - params=TypeAdapter(InitializeRequestParams).dump_python(params), - ) + JSONRPCRequest( + jsonrpc="2.0", + id=1, + method="initialize", + params=TypeAdapter(InitializeRequestParams).dump_python(params), ) ) ) @@ -202,27 +183,16 @@ async def run_server(): response = response.message # Send initialized notification - await send_stream1.send( - SessionMessage( - JSONRPCMessage( - root=JSONRPCNotification( - jsonrpc="2.0", - method="notifications/initialized", - ) - ) - ) - ) + await send_stream1.send(SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized"))) # Call the tool to verify lifespan context await send_stream1.send( SessionMessage( - JSONRPCMessage( - root=JSONRPCRequest( - jsonrpc="2.0", - id=2, - method="tools/call", - params={"name": "check_lifespan", "arguments": {}}, - ) + JSONRPCRequest( + jsonrpc="2.0", + id=2, + method="tools/call", + params={"name": "check_lifespan", "arguments": {}}, ) ) ) @@ -231,8 +201,8 @@ async def run_server(): response = await receive_stream2.receive() response = response.message assert isinstance(response, JSONRPCMessage) - assert isinstance(response.root, JSONRPCResponse) - assert response.root.result["content"][0]["text"] == "true" + assert isinstance(response, JSONRPCResponse) + assert response.result["content"][0]["text"] == "true" # Cancel server task tg.cancel_scope.cancel() diff --git a/tests/server/test_lowlevel_exception_handling.py b/tests/server/test_lowlevel_exception_handling.py index 5d4c3347f..848b35b29 100644 --- a/tests/server/test_lowlevel_exception_handling.py +++ b/tests/server/test_lowlevel_exception_handling.py @@ -2,7 +2,7 @@ import pytest -import mcp.types as types +from mcp import types from mcp.server.lowlevel.server import Server from mcp.server.session import ServerSession from mcp.shared.session import RequestResponder @@ -60,7 +60,7 @@ async def test_normal_message_handling_not_affected(): # Create a mock RequestResponder responder = Mock(spec=RequestResponder) - responder.request = types.ClientRequest(root=types.PingRequest(method="ping")) + responder.request = types.PingRequest(method="ping") responder.__enter__ = Mock(return_value=responder) responder.__exit__ = Mock(return_value=None) diff --git a/tests/server/test_lowlevel_input_validation.py b/tests/server/test_lowlevel_input_validation.py deleted file mode 100644 index 47cb57232..000000000 --- a/tests/server/test_lowlevel_input_validation.py +++ /dev/null @@ -1,311 +0,0 @@ -"""Test input schema validation for lowlevel server.""" - -import logging -from collections.abc import Awaitable, Callable -from typing import Any - -import anyio -import pytest - -from mcp.client.session import ClientSession -from mcp.server import Server -from mcp.server.lowlevel import NotificationOptions -from mcp.server.models import InitializationOptions -from mcp.server.session import ServerSession -from mcp.shared.message import SessionMessage -from mcp.shared.session import RequestResponder -from mcp.types import CallToolResult, ClientResult, ServerNotification, ServerRequest, TextContent, Tool - - -async def run_tool_test( - tools: list[Tool], - call_tool_handler: Callable[[str, dict[str, Any]], Awaitable[list[TextContent]]], - test_callback: Callable[[ClientSession], Awaitable[CallToolResult]], -) -> CallToolResult | None: - """Helper to run a tool test with minimal boilerplate. - - Args: - tools: List of tools to register - call_tool_handler: Handler function for tool calls - test_callback: Async function that performs the test using the client session - - Returns: - The result of the tool call - """ - server = Server("test") - result = None - - @server.list_tools() - async def list_tools(): - return tools - - @server.call_tool() - async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: - return await call_tool_handler(name, arguments) - - server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) - client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) - - # Message handler for client - async def message_handler( - message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, - ) -> None: - if isinstance(message, Exception): # pragma: no cover - raise message - - # Server task - async def run_server(): - async with ServerSession( - client_to_server_receive, - server_to_client_send, - InitializationOptions( - server_name="test-server", - server_version="1.0.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) as server_session: - async with anyio.create_task_group() as tg: - - async def handle_messages(): - async for message in server_session.incoming_messages: - await server._handle_message(message, server_session, {}, False) - - tg.start_soon(handle_messages) - await anyio.sleep_forever() - - # Run the test - async with anyio.create_task_group() as tg: - tg.start_soon(run_server) - - async with ClientSession( - server_to_client_receive, - client_to_server_send, - message_handler=message_handler, - ) as client_session: - # Initialize the session - await client_session.initialize() - - # Run the test callback - result = await test_callback(client_session) - - # Cancel the server task - tg.cancel_scope.cancel() - - return result - - -def create_add_tool() -> Tool: - """Create a standard 'add' tool for testing.""" - return Tool( - name="add", - description="Add two numbers", - inputSchema={ - "type": "object", - "properties": { - "a": {"type": "number"}, - "b": {"type": "number"}, - }, - "required": ["a", "b"], - "additionalProperties": False, - }, - ) - - -@pytest.mark.anyio -async def test_valid_tool_call(): - """Test that valid arguments pass validation.""" - - async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: - if name == "add": - result = arguments["a"] + arguments["b"] - return [TextContent(type="text", text=f"Result: {result}")] - else: # pragma: no cover - raise ValueError(f"Unknown tool: {name}") - - async def test_callback(client_session: ClientSession) -> CallToolResult: - return await client_session.call_tool("add", {"a": 5, "b": 3}) - - result = await run_tool_test([create_add_tool()], call_tool_handler, test_callback) - - # Verify results - assert result is not None - assert not result.isError - assert len(result.content) == 1 - assert result.content[0].type == "text" - assert isinstance(result.content[0], TextContent) - assert result.content[0].text == "Result: 8" - - -@pytest.mark.anyio -async def test_invalid_tool_call_missing_required(): - """Test that missing required arguments fail validation.""" - - async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: # pragma: no cover - # This should not be reached due to validation - raise RuntimeError("Should not reach here") - - async def test_callback(client_session: ClientSession) -> CallToolResult: - return await client_session.call_tool("add", {"a": 5}) # missing 'b' - - result = await run_tool_test([create_add_tool()], call_tool_handler, test_callback) - - # Verify results - assert result is not None - assert result.isError - assert len(result.content) == 1 - assert result.content[0].type == "text" - assert isinstance(result.content[0], TextContent) - assert "Input validation error" in result.content[0].text - assert "'b' is a required property" in result.content[0].text - - -@pytest.mark.anyio -async def test_invalid_tool_call_wrong_type(): - """Test that wrong argument types fail validation.""" - - async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: # pragma: no cover - # This should not be reached due to validation - raise RuntimeError("Should not reach here") - - async def test_callback(client_session: ClientSession) -> CallToolResult: - return await client_session.call_tool("add", {"a": "five", "b": 3}) # 'a' should be number - - result = await run_tool_test([create_add_tool()], call_tool_handler, test_callback) - - # Verify results - assert result is not None - assert result.isError - assert len(result.content) == 1 - assert result.content[0].type == "text" - assert isinstance(result.content[0], TextContent) - assert "Input validation error" in result.content[0].text - assert "'five' is not of type 'number'" in result.content[0].text - - -@pytest.mark.anyio -async def test_cache_refresh_on_missing_tool(): - """Test that tool cache is refreshed when tool is not found.""" - tools = [ - Tool( - name="multiply", - description="Multiply two numbers", - inputSchema={ - "type": "object", - "properties": { - "x": {"type": "number"}, - "y": {"type": "number"}, - }, - "required": ["x", "y"], - }, - ) - ] - - async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: - if name == "multiply": - result = arguments["x"] * arguments["y"] - return [TextContent(type="text", text=f"Result: {result}")] - else: # pragma: no cover - raise ValueError(f"Unknown tool: {name}") - - async def test_callback(client_session: ClientSession) -> CallToolResult: - # Call tool without first listing tools (cache should be empty) - # The cache should be refreshed automatically - return await client_session.call_tool("multiply", {"x": 10, "y": 20}) - - result = await run_tool_test(tools, call_tool_handler, test_callback) - - # Verify results - should work because cache will be refreshed - assert result is not None - assert not result.isError - assert len(result.content) == 1 - assert result.content[0].type == "text" - assert isinstance(result.content[0], TextContent) - assert result.content[0].text == "Result: 200" - - -@pytest.mark.anyio -async def test_enum_constraint_validation(): - """Test that enum constraints are validated.""" - tools = [ - Tool( - name="greet", - description="Greet someone", - inputSchema={ - "type": "object", - "properties": { - "name": {"type": "string"}, - "title": {"type": "string", "enum": ["Mr", "Ms", "Dr"]}, - }, - "required": ["name"], - }, - ) - ] - - async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: # pragma: no cover - # This should not be reached due to validation failure - raise RuntimeError("Should not reach here") - - async def test_callback(client_session: ClientSession) -> CallToolResult: - return await client_session.call_tool("greet", {"name": "Smith", "title": "Prof"}) # Invalid title - - result = await run_tool_test(tools, call_tool_handler, test_callback) - - # Verify results - assert result is not None - assert result.isError - assert len(result.content) == 1 - assert result.content[0].type == "text" - assert isinstance(result.content[0], TextContent) - assert "Input validation error" in result.content[0].text - assert "'Prof' is not one of" in result.content[0].text - - -@pytest.mark.anyio -async def test_tool_not_in_list_logs_warning(caplog: pytest.LogCaptureFixture): - """Test that calling a tool not in list_tools logs a warning and skips validation.""" - tools = [ - Tool( - name="add", - description="Add two numbers", - inputSchema={ - "type": "object", - "properties": { - "a": {"type": "number"}, - "b": {"type": "number"}, - }, - "required": ["a", "b"], - }, - ) - ] - - async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: - # This should be reached since validation is skipped for unknown tools - if name == "unknown_tool": - # Even with invalid arguments, this should execute since validation is skipped - return [TextContent(type="text", text="Unknown tool executed without validation")] - else: # pragma: no cover - raise ValueError(f"Unknown tool: {name}") - - async def test_callback(client_session: ClientSession) -> CallToolResult: - # Call a tool that's not in the list with invalid arguments - # This should trigger the warning about validation not being performed - return await client_session.call_tool("unknown_tool", {"invalid": "args"}) - - with caplog.at_level(logging.WARNING): - result = await run_tool_test(tools, call_tool_handler, test_callback) - - # Verify results - should succeed because validation is skipped for unknown tools - assert result is not None - assert not result.isError - assert len(result.content) == 1 - assert result.content[0].type == "text" - assert isinstance(result.content[0], TextContent) - assert result.content[0].text == "Unknown tool executed without validation" - - # Verify warning was logged - assert any( - "Tool 'unknown_tool' not listed, no validation will be performed" in record.message for record in caplog.records - ) diff --git a/tests/server/test_lowlevel_output_validation.py b/tests/server/test_lowlevel_output_validation.py deleted file mode 100644 index f73544521..000000000 --- a/tests/server/test_lowlevel_output_validation.py +++ /dev/null @@ -1,476 +0,0 @@ -"""Test output schema validation for lowlevel server.""" - -import json -from collections.abc import Awaitable, Callable -from typing import Any - -import anyio -import pytest - -from mcp.client.session import ClientSession -from mcp.server import Server -from mcp.server.lowlevel import NotificationOptions -from mcp.server.models import InitializationOptions -from mcp.server.session import ServerSession -from mcp.shared.message import SessionMessage -from mcp.shared.session import RequestResponder -from mcp.types import CallToolResult, ClientResult, ServerNotification, ServerRequest, TextContent, Tool - - -async def run_tool_test( - tools: list[Tool], - call_tool_handler: Callable[[str, dict[str, Any]], Awaitable[Any]], - test_callback: Callable[[ClientSession], Awaitable[CallToolResult]], -) -> CallToolResult | None: - """Helper to run a tool test with minimal boilerplate. - - Args: - tools: List of tools to register - call_tool_handler: Handler function for tool calls - test_callback: Async function that performs the test using the client session - - Returns: - The result of the tool call - """ - server = Server("test") - - result = None - - @server.list_tools() - async def list_tools(): - return tools - - @server.call_tool() - async def call_tool(name: str, arguments: dict[str, Any]): - return await call_tool_handler(name, arguments) - - server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) - client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) - - # Message handler for client - async def message_handler( # pragma: no cover - message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, - ) -> None: - if isinstance(message, Exception): - raise message - - # Server task - async def run_server(): - async with ServerSession( - client_to_server_receive, - server_to_client_send, - InitializationOptions( - server_name="test-server", - server_version="1.0.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) as server_session: - async with anyio.create_task_group() as tg: - - async def handle_messages(): - async for message in server_session.incoming_messages: - await server._handle_message(message, server_session, {}, False) - - tg.start_soon(handle_messages) - await anyio.sleep_forever() - - # Run the test - async with anyio.create_task_group() as tg: - tg.start_soon(run_server) - - async with ClientSession( - server_to_client_receive, - client_to_server_send, - message_handler=message_handler, - ) as client_session: - # Initialize the session - await client_session.initialize() - - # Run the test callback - result = await test_callback(client_session) - - # Cancel the server task - tg.cancel_scope.cancel() - - return result - - -@pytest.mark.anyio -async def test_content_only_without_output_schema(): - """Test returning content only when no outputSchema is defined.""" - tools = [ - Tool( - name="echo", - description="Echo a message", - inputSchema={ - "type": "object", - "properties": { - "message": {"type": "string"}, - }, - "required": ["message"], - }, - # No outputSchema defined - ) - ] - - async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: - if name == "echo": - return [TextContent(type="text", text=f"Echo: {arguments['message']}")] - else: # pragma: no cover - raise ValueError(f"Unknown tool: {name}") - - async def test_callback(client_session: ClientSession) -> CallToolResult: - return await client_session.call_tool("echo", {"message": "Hello"}) - - result = await run_tool_test(tools, call_tool_handler, test_callback) - - # Verify results - assert result is not None - assert not result.isError - assert len(result.content) == 1 - assert result.content[0].type == "text" - assert isinstance(result.content[0], TextContent) - assert result.content[0].text == "Echo: Hello" - assert result.structuredContent is None - - -@pytest.mark.anyio -async def test_dict_only_without_output_schema(): - """Test returning dict only when no outputSchema is defined.""" - tools = [ - Tool( - name="get_info", - description="Get structured information", - inputSchema={ - "type": "object", - "properties": {}, - }, - # No outputSchema defined - ) - ] - - async def call_tool_handler(name: str, arguments: dict[str, Any]) -> dict[str, Any]: - if name == "get_info": - return {"status": "ok", "data": {"value": 42}} - else: # pragma: no cover - raise ValueError(f"Unknown tool: {name}") - - async def test_callback(client_session: ClientSession) -> CallToolResult: - return await client_session.call_tool("get_info", {}) - - result = await run_tool_test(tools, call_tool_handler, test_callback) - - # Verify results - assert result is not None - assert not result.isError - assert len(result.content) == 1 - assert result.content[0].type == "text" - assert isinstance(result.content[0], TextContent) - # Check that the content is the JSON serialization - assert json.loads(result.content[0].text) == {"status": "ok", "data": {"value": 42}} - assert result.structuredContent == {"status": "ok", "data": {"value": 42}} - - -@pytest.mark.anyio -async def test_both_content_and_dict_without_output_schema(): - """Test returning both content and dict when no outputSchema is defined.""" - tools = [ - Tool( - name="process", - description="Process data", - inputSchema={ - "type": "object", - "properties": {}, - }, - # No outputSchema defined - ) - ] - - async def call_tool_handler(name: str, arguments: dict[str, Any]) -> tuple[list[TextContent], dict[str, Any]]: - if name == "process": - content = [TextContent(type="text", text="Processing complete")] - data = {"result": "success", "count": 10} - return (content, data) - else: # pragma: no cover - raise ValueError(f"Unknown tool: {name}") - - async def test_callback(client_session: ClientSession) -> CallToolResult: - return await client_session.call_tool("process", {}) - - result = await run_tool_test(tools, call_tool_handler, test_callback) - - # Verify results - assert result is not None - assert not result.isError - assert len(result.content) == 1 - assert result.content[0].type == "text" - assert isinstance(result.content[0], TextContent) - assert result.content[0].text == "Processing complete" - assert result.structuredContent == {"result": "success", "count": 10} - - -@pytest.mark.anyio -async def test_content_only_with_output_schema_error(): - """Test error when outputSchema is defined but only content is returned.""" - tools = [ - Tool( - name="structured_tool", - description="Tool expecting structured output", - inputSchema={ - "type": "object", - "properties": {}, - }, - outputSchema={ - "type": "object", - "properties": { - "result": {"type": "string"}, - }, - "required": ["result"], - }, - ) - ] - - async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: - # This returns only content, but outputSchema expects structured data - return [TextContent(type="text", text="This is not structured")] - - async def test_callback(client_session: ClientSession) -> CallToolResult: - return await client_session.call_tool("structured_tool", {}) - - result = await run_tool_test(tools, call_tool_handler, test_callback) - - # Verify error - assert result is not None - assert result.isError - assert len(result.content) == 1 - assert result.content[0].type == "text" - assert isinstance(result.content[0], TextContent) - assert "Output validation error: outputSchema defined but no structured output returned" in result.content[0].text - - -@pytest.mark.anyio -async def test_valid_dict_with_output_schema(): - """Test valid dict output matching outputSchema.""" - tools = [ - Tool( - name="calc", - description="Calculate result", - inputSchema={ - "type": "object", - "properties": { - "x": {"type": "number"}, - "y": {"type": "number"}, - }, - "required": ["x", "y"], - }, - outputSchema={ - "type": "object", - "properties": { - "sum": {"type": "number"}, - "product": {"type": "number"}, - }, - "required": ["sum", "product"], - }, - ) - ] - - async def call_tool_handler(name: str, arguments: dict[str, Any]) -> dict[str, Any]: - if name == "calc": - x = arguments["x"] - y = arguments["y"] - return {"sum": x + y, "product": x * y} - else: # pragma: no cover - raise ValueError(f"Unknown tool: {name}") - - async def test_callback(client_session: ClientSession) -> CallToolResult: - return await client_session.call_tool("calc", {"x": 3, "y": 4}) - - result = await run_tool_test(tools, call_tool_handler, test_callback) - - # Verify results - assert result is not None - assert not result.isError - assert len(result.content) == 1 - assert result.content[0].type == "text" - # Check JSON serialization - assert json.loads(result.content[0].text) == {"sum": 7, "product": 12} - assert result.structuredContent == {"sum": 7, "product": 12} - - -@pytest.mark.anyio -async def test_invalid_dict_with_output_schema(): - """Test dict output that doesn't match outputSchema.""" - tools = [ - Tool( - name="user_info", - description="Get user information", - inputSchema={ - "type": "object", - "properties": {}, - }, - outputSchema={ - "type": "object", - "properties": { - "name": {"type": "string"}, - "age": {"type": "integer"}, - }, - "required": ["name", "age"], - }, - ) - ] - - async def call_tool_handler(name: str, arguments: dict[str, Any]) -> dict[str, Any]: - if name == "user_info": - # Missing required 'age' field - return {"name": "Alice"} - else: # pragma: no cover - raise ValueError(f"Unknown tool: {name}") - - async def test_callback(client_session: ClientSession) -> CallToolResult: - return await client_session.call_tool("user_info", {}) - - result = await run_tool_test(tools, call_tool_handler, test_callback) - - # Verify error - assert result is not None - assert result.isError - assert len(result.content) == 1 - assert result.content[0].type == "text" - assert isinstance(result.content[0], TextContent) - assert "Output validation error:" in result.content[0].text - assert "'age' is a required property" in result.content[0].text - - -@pytest.mark.anyio -async def test_both_content_and_valid_dict_with_output_schema(): - """Test returning both content and valid dict with outputSchema.""" - tools = [ - Tool( - name="analyze", - description="Analyze data", - inputSchema={ - "type": "object", - "properties": { - "text": {"type": "string"}, - }, - "required": ["text"], - }, - outputSchema={ - "type": "object", - "properties": { - "sentiment": {"type": "string", "enum": ["positive", "negative", "neutral"]}, - "confidence": {"type": "number", "minimum": 0, "maximum": 1}, - }, - "required": ["sentiment", "confidence"], - }, - ) - ] - - async def call_tool_handler(name: str, arguments: dict[str, Any]) -> tuple[list[TextContent], dict[str, Any]]: - if name == "analyze": - content = [TextContent(type="text", text=f"Analysis of: {arguments['text']}")] - data = {"sentiment": "positive", "confidence": 0.95} - return (content, data) - else: # pragma: no cover - raise ValueError(f"Unknown tool: {name}") - - async def test_callback(client_session: ClientSession) -> CallToolResult: - return await client_session.call_tool("analyze", {"text": "Great job!"}) - - result = await run_tool_test(tools, call_tool_handler, test_callback) - - # Verify results - assert result is not None - assert not result.isError - assert len(result.content) == 1 - assert result.content[0].type == "text" - assert result.content[0].text == "Analysis of: Great job!" - assert result.structuredContent == {"sentiment": "positive", "confidence": 0.95} - - -@pytest.mark.anyio -async def test_tool_call_result(): - """Test returning ToolCallResult when no outputSchema is defined.""" - tools = [ - Tool( - name="get_info", - description="Get structured information", - inputSchema={ - "type": "object", - "properties": {}, - }, - # No outputSchema for direct return of tool call result - ) - ] - - async def call_tool_handler(name: str, arguments: dict[str, Any]) -> CallToolResult: - if name == "get_info": - return CallToolResult( - content=[TextContent(type="text", text="Results calculated")], - structuredContent={"status": "ok", "data": {"value": 42}}, - _meta={"some": "metadata"}, - ) - else: # pragma: no cover - raise ValueError(f"Unknown tool: {name}") - - async def test_callback(client_session: ClientSession) -> CallToolResult: - return await client_session.call_tool("get_info", {}) - - result = await run_tool_test(tools, call_tool_handler, test_callback) - - # Verify results - assert result is not None - assert not result.isError - assert len(result.content) == 1 - assert result.content[0].type == "text" - assert result.content[0].text == "Results calculated" - assert isinstance(result.content[0], TextContent) - assert result.structuredContent == {"status": "ok", "data": {"value": 42}} - assert result.meta == {"some": "metadata"} - - -@pytest.mark.anyio -async def test_output_schema_type_validation(): - """Test outputSchema validates types correctly.""" - tools = [ - Tool( - name="stats", - description="Get statistics", - inputSchema={ - "type": "object", - "properties": {}, - }, - outputSchema={ - "type": "object", - "properties": { - "count": {"type": "integer"}, - "average": {"type": "number"}, - "items": {"type": "array", "items": {"type": "string"}}, - }, - "required": ["count", "average", "items"], - }, - ) - ] - - async def call_tool_handler(name: str, arguments: dict[str, Any]) -> dict[str, Any]: - if name == "stats": - # Wrong type for 'count' - should be integer - return {"count": "five", "average": 2.5, "items": ["a", "b"]} - else: # pragma: no cover - raise ValueError(f"Unknown tool: {name}") - - async def test_callback(client_session: ClientSession) -> CallToolResult: - return await client_session.call_tool("stats", {}) - - result = await run_tool_test(tools, call_tool_handler, test_callback) - - # Verify error - assert result is not None - assert result.isError - assert len(result.content) == 1 - assert result.content[0].type == "text" - assert "Output validation error:" in result.content[0].text - assert "'five' is not of type 'integer'" in result.content[0].text diff --git a/tests/server/test_lowlevel_tool_annotations.py b/tests/server/test_lowlevel_tool_annotations.py index f812c4877..705abdfe8 100644 --- a/tests/server/test_lowlevel_tool_annotations.py +++ b/tests/server/test_lowlevel_tool_annotations.py @@ -1,100 +1,44 @@ """Tests for tool annotations in low-level server.""" -import anyio import pytest -from mcp.client.session import ClientSession -from mcp.server import Server -from mcp.server.lowlevel import NotificationOptions -from mcp.server.models import InitializationOptions -from mcp.server.session import ServerSession -from mcp.shared.message import SessionMessage -from mcp.shared.session import RequestResponder -from mcp.types import ClientResult, ServerNotification, ServerRequest, Tool, ToolAnnotations +from mcp import Client +from mcp.server import Server, ServerRequestContext +from mcp.types import ListToolsResult, PaginatedRequestParams, Tool, ToolAnnotations @pytest.mark.anyio async def test_lowlevel_server_tool_annotations(): """Test that tool annotations work in low-level server.""" - server = Server("test") - # Create a tool with annotations - @server.list_tools() - async def list_tools(): # pragma: no cover - return [ - Tool( - name="echo", - description="Echo a message back", - inputSchema={ - "type": "object", - "properties": { - "message": {"type": "string"}, + async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult( + tools=[ + Tool( + name="echo", + description="Echo a message back", + input_schema={ + "type": "object", + "properties": { + "message": {"type": "string"}, + }, + "required": ["message"], }, - "required": ["message"], - }, - annotations=ToolAnnotations( - title="Echo Tool", - readOnlyHint=True, - ), - ) - ] - - tools_result = None - server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) - client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) - - # Message handler for client - async def message_handler( - message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, - ) -> None: - if isinstance(message, Exception): # pragma: no cover - raise message - - # Server task - async def run_server(): - async with ServerSession( - client_to_server_receive, - server_to_client_send, - InitializationOptions( - server_name="test-server", - server_version="1.0.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) as server_session: - async with anyio.create_task_group() as tg: - - async def handle_messages(): - async for message in server_session.incoming_messages: - await server._handle_message(message, server_session, {}, False) - - tg.start_soon(handle_messages) - await anyio.sleep_forever() - - # Run the test - async with anyio.create_task_group() as tg: - tg.start_soon(run_server) - - async with ClientSession( - server_to_client_receive, - client_to_server_send, - message_handler=message_handler, - ) as client_session: - # Initialize the session - await client_session.initialize() - - # List tools - tools_result = await client_session.list_tools() - - # Cancel the server task - tg.cancel_scope.cancel() - - # Verify results - assert tools_result is not None - assert len(tools_result.tools) == 1 - assert tools_result.tools[0].name == "echo" - assert tools_result.tools[0].annotations is not None - assert tools_result.tools[0].annotations.title == "Echo Tool" - assert tools_result.tools[0].annotations.readOnlyHint is True + annotations=ToolAnnotations( + title="Echo Tool", + read_only_hint=True, + ), + ) + ] + ) + + server = Server("test", on_list_tools=handle_list_tools) + + async with Client(server) as client: + tools_result = await client.list_tools() + + assert len(tools_result.tools) == 1 + assert tools_result.tools[0].name == "echo" + assert tools_result.tools[0].annotations is not None + assert tools_result.tools[0].annotations.title == "Echo Tool" + assert tools_result.tools[0].annotations.read_only_hint is True diff --git a/tests/server/test_read_resource.py b/tests/server/test_read_resource.py index c31b90c55..102a58d03 100644 --- a/tests/server/test_read_resource.py +++ b/tests/server/test_read_resource.py @@ -1,107 +1,58 @@ -from collections.abc import Iterable -from pathlib import Path -from tempfile import NamedTemporaryFile +import base64 import pytest -from pydantic import AnyUrl, FileUrl -import mcp.types as types -from mcp.server.lowlevel.server import ReadResourceContents, Server +from mcp import Client +from mcp.server import Server, ServerRequestContext +from mcp.types import ( + BlobResourceContents, + ReadResourceRequestParams, + ReadResourceResult, + TextResourceContents, +) +pytestmark = pytest.mark.anyio -@pytest.fixture -def temp_file(): - """Create a temporary file for testing.""" - with NamedTemporaryFile(mode="w", delete=False) as f: - f.write("test content") - path = Path(f.name).resolve() - yield path - try: - path.unlink() - except FileNotFoundError: # pragma: no cover - pass +async def test_read_resource_text(): + async def handle_read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult: + return ReadResourceResult( + contents=[TextResourceContents(uri=str(params.uri), text="Hello World", mime_type="text/plain")] + ) -@pytest.mark.anyio -async def test_read_resource_text(temp_file: Path): - server = Server("test") + server = Server("test", on_read_resource=handle_read_resource) - @server.read_resource() - async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]: - return [ReadResourceContents(content="Hello World", mime_type="text/plain")] + async with Client(server) as client: + result = await client.read_resource("test://resource") + assert len(result.contents) == 1 - # Get the handler directly from the server - handler = server.request_handlers[types.ReadResourceRequest] + content = result.contents[0] + assert isinstance(content, TextResourceContents) + assert content.text == "Hello World" + assert content.mime_type == "text/plain" - # Create a request - request = types.ReadResourceRequest( - params=types.ReadResourceRequestParams(uri=FileUrl(temp_file.as_uri())), - ) - # Call the handler - result = await handler(request) - assert isinstance(result.root, types.ReadResourceResult) - assert len(result.root.contents) == 1 +async def test_read_resource_binary(): + binary_data = b"Hello World" - content = result.root.contents[0] - assert isinstance(content, types.TextResourceContents) - assert content.text == "Hello World" - assert content.mimeType == "text/plain" + async def handle_read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult: + return ReadResourceResult( + contents=[ + BlobResourceContents( + uri=str(params.uri), + blob=base64.b64encode(binary_data).decode("utf-8"), + mime_type="application/octet-stream", + ) + ] + ) + server = Server("test", on_read_resource=handle_read_resource) -@pytest.mark.anyio -async def test_read_resource_binary(temp_file: Path): - server = Server("test") + async with Client(server) as client: + result = await client.read_resource("test://resource") + assert len(result.contents) == 1 - @server.read_resource() - async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]: - return [ReadResourceContents(content=b"Hello World", mime_type="application/octet-stream")] - - # Get the handler directly from the server - handler = server.request_handlers[types.ReadResourceRequest] - - # Create a request - request = types.ReadResourceRequest( - params=types.ReadResourceRequestParams(uri=FileUrl(temp_file.as_uri())), - ) - - # Call the handler - result = await handler(request) - assert isinstance(result.root, types.ReadResourceResult) - assert len(result.root.contents) == 1 - - content = result.root.contents[0] - assert isinstance(content, types.BlobResourceContents) - assert content.mimeType == "application/octet-stream" - - -@pytest.mark.anyio -async def test_read_resource_default_mime(temp_file: Path): - server = Server("test") - - @server.read_resource() - async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]: - return [ - ReadResourceContents( - content="Hello World", - # No mime_type specified, should default to text/plain - ) - ] - - # Get the handler directly from the server - handler = server.request_handlers[types.ReadResourceRequest] - - # Create a request - request = types.ReadResourceRequest( - params=types.ReadResourceRequestParams(uri=FileUrl(temp_file.as_uri())), - ) - - # Call the handler - result = await handler(request) - assert isinstance(result.root, types.ReadResourceResult) - assert len(result.root.contents) == 1 - - content = result.root.contents[0] - assert isinstance(content, types.TextResourceContents) - assert content.text == "Hello World" - assert content.mimeType == "text/plain" + content = result.contents[0] + assert isinstance(content, BlobResourceContents) + assert content.mime_type == "application/octet-stream" + assert base64.b64decode(content.blob) == binary_data diff --git a/tests/server/test_session.py b/tests/server/test_session.py index 34f9c6e28..a2786d865 100644 --- a/tests/server/test_session.py +++ b/tests/server/test_session.py @@ -3,28 +3,21 @@ import anyio import pytest -import mcp.types as types +from mcp import types from mcp.client.session import ClientSession -from mcp.server import Server +from mcp.server import Server, ServerRequestContext from mcp.server.lowlevel import NotificationOptions from mcp.server.models import InitializationOptions from mcp.server.session import ServerSession -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.shared.message import SessionMessage from mcp.shared.session import RequestResponder from mcp.types import ( ClientNotification, - Completion, - CompletionArgument, - CompletionContext, CompletionsCapability, InitializedNotification, - Prompt, - PromptReference, PromptsCapability, - Resource, ResourcesCapability, - ResourceTemplateReference, ServerCapabilities, ) @@ -60,7 +53,7 @@ async def run_server(): raise message if isinstance(message, ClientNotification) and isinstance( - message.root, InitializedNotification + message, InitializedNotification ): # pragma: no branch received_initialized = True return @@ -85,50 +78,53 @@ async def run_server(): @pytest.mark.anyio async def test_server_capabilities(): - server = Server("test") notification_options = NotificationOptions() experimental_capabilities: dict[str, Any] = {} - # Initially no capabilities + async def noop_list_prompts( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListPromptsResult: + raise NotImplementedError + + async def noop_list_resources( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListResourcesResult: + raise NotImplementedError + + async def noop_completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> types.CompleteResult: + raise NotImplementedError + + # No capabilities + server = Server("test") caps = server.get_capabilities(notification_options, experimental_capabilities) assert caps.prompts is None assert caps.resources is None assert caps.completions is None - # Add a prompts handler - @server.list_prompts() - async def list_prompts() -> list[Prompt]: # pragma: no cover - return [] - + # With prompts handler + server = Server("test", on_list_prompts=noop_list_prompts) caps = server.get_capabilities(notification_options, experimental_capabilities) - assert caps.prompts == PromptsCapability(listChanged=False) + assert caps.prompts == PromptsCapability(list_changed=False) assert caps.resources is None assert caps.completions is None - # Add a resources handler - @server.list_resources() - async def list_resources() -> list[Resource]: # pragma: no cover - return [] - + # With prompts + resources handlers + server = Server("test", on_list_prompts=noop_list_prompts, on_list_resources=noop_list_resources) caps = server.get_capabilities(notification_options, experimental_capabilities) - assert caps.prompts == PromptsCapability(listChanged=False) - assert caps.resources == ResourcesCapability(subscribe=False, listChanged=False) + assert caps.prompts == PromptsCapability(list_changed=False) + assert caps.resources == ResourcesCapability(subscribe=False, list_changed=False) assert caps.completions is None - # Add a complete handler - @server.completion() - async def complete( # pragma: no cover - ref: PromptReference | ResourceTemplateReference, - argument: CompletionArgument, - context: CompletionContext | None, - ) -> Completion | None: - return Completion( - values=["completion1", "completion2"], - ) - + # With prompts + resources + completion handlers + server = Server( + "test", + on_list_prompts=noop_list_prompts, + on_list_resources=noop_list_resources, + on_completion=noop_completion, + ) caps = server.get_capabilities(notification_options, experimental_capabilities) - assert caps.prompts == PromptsCapability(listChanged=False) - assert caps.resources == ResourcesCapability(subscribe=False, listChanged=False) + assert caps.prompts == PromptsCapability(list_changed=False) + assert caps.resources == ResourcesCapability(subscribe=False, list_changed=False) assert caps.completions == CompletionsCapability() @@ -158,7 +154,7 @@ async def run_server(): raise message if isinstance(message, types.ClientNotification) and isinstance( - message.root, InitializedNotification + message, InitializedNotification ): # pragma: no branch received_initialized = True return @@ -169,41 +165,32 @@ async def mock_client(): # Send initialization request with older protocol version (2024-11-05) await client_to_server_send.send( SessionMessage( - types.JSONRPCMessage( - types.JSONRPCRequest( - jsonrpc="2.0", - id=1, - method="initialize", - params=types.InitializeRequestParams( - protocolVersion="2024-11-05", - capabilities=types.ClientCapabilities(), - clientInfo=types.Implementation(name="test-client", version="1.0.0"), - ).model_dump(by_alias=True, mode="json", exclude_none=True), - ) + types.JSONRPCRequest( + jsonrpc="2.0", + id=1, + method="initialize", + params=types.InitializeRequestParams( + protocol_version="2024-11-05", + capabilities=types.ClientCapabilities(), + client_info=types.Implementation(name="test-client", version="1.0.0"), + ).model_dump(by_alias=True, mode="json", exclude_none=True), ) ) ) # Wait for the initialize response init_response_message = await server_to_client_receive.receive() - assert isinstance(init_response_message.message.root, types.JSONRPCResponse) - result_data = init_response_message.message.root.result + assert isinstance(init_response_message.message, types.JSONRPCResponse) + result_data = init_response_message.message.result init_result = types.InitializeResult.model_validate(result_data) # Check that the server responded with the requested protocol version - received_protocol_version = init_result.protocolVersion + received_protocol_version = init_result.protocol_version assert received_protocol_version == "2024-11-05" # Send initialized notification await client_to_server_send.send( - SessionMessage( - types.JSONRPCMessage( - types.JSONRPCNotification( - jsonrpc="2.0", - method="notifications/initialized", - ) - ) - ) + SessionMessage(types.JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized")) ) async with ( @@ -245,35 +232,25 @@ async def run_server(): # We should receive a ping request before initialization if isinstance(message, RequestResponder) and isinstance( - message.request.root, types.PingRequest + message.request, types.PingRequest ): # pragma: no branch # Respond to the ping with message: - await message.respond(types.ServerResult(types.EmptyResult())) + await message.respond(types.EmptyResult()) return async def mock_client(): nonlocal ping_response_received, ping_response_id # Send ping request before any initialization - await client_to_server_send.send( - SessionMessage( - types.JSONRPCMessage( - types.JSONRPCRequest( - jsonrpc="2.0", - id=42, - method="ping", - ) - ) - ) - ) + await client_to_server_send.send(SessionMessage(types.JSONRPCRequest(jsonrpc="2.0", id=42, method="ping"))) # Wait for the ping response ping_response_message = await server_to_client_receive.receive() - assert isinstance(ping_response_message.message.root, types.JSONRPCResponse) + assert isinstance(ping_response_message.message, types.JSONRPCResponse) ping_response_received = True - ping_response_id = ping_response_message.message.root.id + ping_response_id = ping_response_message.message.id async with ( client_to_server_send, @@ -312,17 +289,17 @@ async def test_create_message_tool_result_validation(): ) as session: # Set up client params with sampling.tools capability for the test session._client_params = types.InitializeRequestParams( - protocolVersion=types.LATEST_PROTOCOL_VERSION, + protocol_version=types.LATEST_PROTOCOL_VERSION, capabilities=types.ClientCapabilities( sampling=types.SamplingCapability(tools=types.SamplingToolsCapability()) ), - clientInfo=types.Implementation(name="test", version="1.0"), + client_info=types.Implementation(name="test", version="1.0"), ) - tool = types.Tool(name="test_tool", inputSchema={"type": "object"}) + tool = types.Tool(name="test_tool", input_schema={"type": "object"}) text = types.TextContent(type="text", text="hello") tool_use = types.ToolUseContent(type="tool_use", id="call_1", name="test_tool", input={}) - tool_result = types.ToolResultContent(type="tool_result", toolUseId="call_1", content=[]) + tool_result = types.ToolResultContent(type="tool_result", tool_use_id="call_1", content=[]) # Case 1: tool_result mixed with other content with pytest.raises(ValueError, match="only tool_result content"): @@ -363,7 +340,7 @@ async def test_create_message_tool_result_validation(): types.SamplingMessage(role="assistant", content=tool_use), types.SamplingMessage( role="user", - content=types.ToolResultContent(type="tool_result", toolUseId="wrong_id", content=[]), + content=types.ToolResultContent(type="tool_result", tool_use_id="wrong_id", content=[]), ), ], max_tokens=100, @@ -410,16 +387,13 @@ async def test_create_message_tool_result_validation(): # Case 8: empty messages list - skips validation entirely # Covers the `if messages:` branch (line 280->302) - with anyio.move_on_after(0.01): - await session.create_message( - messages=[], - max_tokens=100, - ) + with anyio.move_on_after(0.01): # pragma: no branch + await session.create_message(messages=[], max_tokens=100) @pytest.mark.anyio async def test_create_message_without_tools_capability(): - """Test that create_message raises McpError when tools are provided without capability.""" + """Test that create_message raises MCPError when tools are provided without capability.""" server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage | Exception](1) @@ -440,16 +414,16 @@ async def test_create_message_without_tools_capability(): ) as session: # Set up client params WITHOUT sampling.tools capability session._client_params = types.InitializeRequestParams( - protocolVersion=types.LATEST_PROTOCOL_VERSION, + protocol_version=types.LATEST_PROTOCOL_VERSION, capabilities=types.ClientCapabilities(sampling=types.SamplingCapability()), - clientInfo=types.Implementation(name="test", version="1.0"), + client_info=types.Implementation(name="test", version="1.0"), ) - tool = types.Tool(name="test_tool", inputSchema={"type": "object"}) + tool = types.Tool(name="test_tool", input_schema={"type": "object"}) text = types.TextContent(type="text", text="hello") - # Should raise McpError when tools are provided but client lacks capability - with pytest.raises(McpError) as exc_info: + # Should raise MCPError when tools are provided but client lacks capability + with pytest.raises(MCPError) as exc_info: await session.create_message( messages=[types.SamplingMessage(role="user", content=text)], max_tokens=100, @@ -457,8 +431,8 @@ async def test_create_message_without_tools_capability(): ) assert "does not support sampling tools capability" in exc_info.value.error.message - # Should also raise McpError when tool_choice is provided - with pytest.raises(McpError) as exc_info: + # Should also raise MCPError when tool_choice is provided + with pytest.raises(MCPError) as exc_info: await session.create_message( messages=[types.SamplingMessage(role="user", content=text)], max_tokens=100, @@ -495,22 +469,14 @@ async def mock_client(): # Try to send a non-ping request before initialization await client_to_server_send.send( - SessionMessage( - types.JSONRPCMessage( - types.JSONRPCRequest( - jsonrpc="2.0", - id=1, - method="prompts/list", - ) - ) - ) + SessionMessage(types.JSONRPCRequest(jsonrpc="2.0", id=1, method="prompts/list")) ) # Wait for the error response error_message = await server_to_client_receive.receive() - if isinstance(error_message.message.root, types.JSONRPCError): # pragma: no branch + if isinstance(error_message.message, types.JSONRPCError): # pragma: no branch error_response_received = True - error_code = error_message.message.root.error.code + error_code = error_message.message.error.code async with ( client_to_server_send, diff --git a/tests/server/test_session_race_condition.py b/tests/server/test_session_race_condition.py index b5388167a..81041152b 100644 --- a/tests/server/test_session_race_condition.py +++ b/tests/server/test_session_race_condition.py @@ -1,5 +1,4 @@ -""" -Test for race condition fix in initialization flow. +"""Test for race condition fix in initialization flow. This test verifies that requests can be processed immediately after responding to InitializeRequest, without waiting for InitializedNotification. @@ -10,7 +9,7 @@ import anyio import pytest -import mcp.types as types +from mcp import types from mcp.server.models import InitializationOptions from mcp.server.session import ServerSession from mcp.shared.message import SessionMessage @@ -20,8 +19,7 @@ @pytest.mark.anyio async def test_request_immediately_after_initialize_response(): - """ - Test that requests are accepted immediately after initialize response. + """Test that requests are accepted immediately after initialize response. This reproduces the race condition in stateful HTTP mode where: 1. Client sends InitializeRequest @@ -49,7 +47,7 @@ async def run_server(): server_name="test-server", server_version="1.0.0", capabilities=ServerCapabilities( - tools=types.ToolsCapability(listChanged=False), + tools=types.ToolsCapability(list_changed=False), ), ), ) as server_session: @@ -59,27 +57,25 @@ async def run_server(): # Handle tools/list request if isinstance(message, RequestResponder): - if isinstance(message.request.root, types.ListToolsRequest): # pragma: no branch + if isinstance(message.request, types.ListToolsRequest): # pragma: no branch tools_list_success = True # Respond with a tool list with message: await message.respond( - types.ServerResult( - types.ListToolsResult( - tools=[ - Tool( - name="example_tool", - description="An example tool", - inputSchema={"type": "object", "properties": {}}, - ) - ] - ) + types.ListToolsResult( + tools=[ + Tool( + name="example_tool", + description="An example tool", + input_schema={"type": "object", "properties": {}}, + ) + ] ) ) # Handle InitializedNotification if isinstance(message, types.ClientNotification): - if isinstance(message.root, types.InitializedNotification): # pragma: no branch + if isinstance(message, types.InitializedNotification): # pragma: no branch # Done - exit gracefully return @@ -89,54 +85,35 @@ async def mock_client(): # Step 1: Send InitializeRequest await client_to_server_send.send( SessionMessage( - types.JSONRPCMessage( - types.JSONRPCRequest( - jsonrpc="2.0", - id=1, - method="initialize", - params=types.InitializeRequestParams( - protocolVersion=types.LATEST_PROTOCOL_VERSION, - capabilities=types.ClientCapabilities(), - clientInfo=types.Implementation(name="test-client", version="1.0.0"), - ).model_dump(by_alias=True, mode="json", exclude_none=True), - ) + types.JSONRPCRequest( + jsonrpc="2.0", + id=1, + method="initialize", + params=types.InitializeRequestParams( + protocol_version=types.LATEST_PROTOCOL_VERSION, + capabilities=types.ClientCapabilities(), + client_info=types.Implementation(name="test-client", version="1.0.0"), + ).model_dump(by_alias=True, mode="json", exclude_none=True), ) ) ) # Step 2: Wait for InitializeResult init_msg = await server_to_client_receive.receive() - assert isinstance(init_msg.message.root, types.JSONRPCResponse) + assert isinstance(init_msg.message, types.JSONRPCResponse) # Step 3: Immediately send tools/list BEFORE InitializedNotification # This is the race condition scenario - await client_to_server_send.send( - SessionMessage( - types.JSONRPCMessage( - types.JSONRPCRequest( - jsonrpc="2.0", - id=2, - method="tools/list", - ) - ) - ) - ) + await client_to_server_send.send(SessionMessage(types.JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list"))) # Step 4: Check the response tools_msg = await server_to_client_receive.receive() - if isinstance(tools_msg.message.root, types.JSONRPCError): # pragma: no cover - error_received = tools_msg.message.root.error.message + if isinstance(tools_msg.message, types.JSONRPCError): # pragma: no cover + error_received = tools_msg.message.error.message # Step 5: Send InitializedNotification await client_to_server_send.send( - SessionMessage( - types.JSONRPCMessage( - types.JSONRPCNotification( - jsonrpc="2.0", - method="notifications/initialized", - ) - ) - ) + SessionMessage(types.JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized")) ) async with ( diff --git a/tests/server/test_stateless_mode.py b/tests/server/test_stateless_mode.py new file mode 100644 index 000000000..3bfc6e674 --- /dev/null +++ b/tests/server/test_stateless_mode.py @@ -0,0 +1,177 @@ +"""Tests for stateless HTTP mode limitations. + +Stateless HTTP mode does not support server-to-client requests because there +is no persistent connection for bidirectional communication. These tests verify +that appropriate errors are raised when attempting to use unsupported features. + +See: https://github.com/modelcontextprotocol/python-sdk/issues/1097 +""" + +from collections.abc import AsyncGenerator +from typing import Any + +import anyio +import pytest + +from mcp import types +from mcp.server.models import InitializationOptions +from mcp.server.session import ServerSession +from mcp.shared.exceptions import StatelessModeNotSupported +from mcp.shared.message import SessionMessage +from mcp.types import ServerCapabilities + + +@pytest.fixture +async def stateless_session() -> AsyncGenerator[ServerSession, None]: + """Create a stateless ServerSession for testing.""" + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage | Exception](1) + + init_options = InitializationOptions( + server_name="test", + server_version="0.1.0", + capabilities=ServerCapabilities(), + ) + + async with ( + client_to_server_send, + client_to_server_receive, + server_to_client_send, + server_to_client_receive, + ): + async with ServerSession( + client_to_server_receive, + server_to_client_send, + init_options, + stateless=True, + ) as session: + yield session + + +@pytest.mark.anyio +async def test_list_roots_fails_in_stateless_mode(stateless_session: ServerSession): + """Test that list_roots raises StatelessModeNotSupported in stateless mode.""" + with pytest.raises(StatelessModeNotSupported, match="list_roots"): + await stateless_session.list_roots() + + +@pytest.mark.anyio +async def test_create_message_fails_in_stateless_mode(stateless_session: ServerSession): + """Test that create_message raises StatelessModeNotSupported in stateless mode.""" + with pytest.raises(StatelessModeNotSupported, match="sampling"): + await stateless_session.create_message( + messages=[ + types.SamplingMessage( + role="user", + content=types.TextContent(type="text", text="hello"), + ) + ], + max_tokens=100, + ) + + +@pytest.mark.anyio +async def test_elicit_form_fails_in_stateless_mode(stateless_session: ServerSession): + """Test that elicit_form raises StatelessModeNotSupported in stateless mode.""" + with pytest.raises(StatelessModeNotSupported, match="elicitation"): + await stateless_session.elicit_form( + message="Please provide input", + requested_schema={"type": "object", "properties": {}}, + ) + + +@pytest.mark.anyio +async def test_elicit_url_fails_in_stateless_mode(stateless_session: ServerSession): + """Test that elicit_url raises StatelessModeNotSupported in stateless mode.""" + with pytest.raises(StatelessModeNotSupported, match="elicitation"): + await stateless_session.elicit_url( + message="Please authenticate", + url="https://example.com/auth", + elicitation_id="test-123", + ) + + +@pytest.mark.anyio +async def test_elicit_deprecated_fails_in_stateless_mode(stateless_session: ServerSession): + """Test that the deprecated elicit method also fails in stateless mode.""" + with pytest.raises(StatelessModeNotSupported, match="elicitation"): + await stateless_session.elicit( + message="Please provide input", + requested_schema={"type": "object", "properties": {}}, + ) + + +@pytest.mark.anyio +async def test_stateless_error_message_is_actionable(stateless_session: ServerSession): + """Test that the error message provides actionable guidance.""" + with pytest.raises(StatelessModeNotSupported) as exc_info: + await stateless_session.list_roots() + + error_message = str(exc_info.value) + # Should mention it's stateless mode + assert "stateless HTTP mode" in error_message + # Should explain why it doesn't work + assert "server-to-client requests" in error_message + # Should tell user how to fix it + assert "stateless_http=False" in error_message + + +@pytest.mark.anyio +async def test_exception_has_method_attribute(stateless_session: ServerSession): + """Test that the exception has a method attribute for programmatic access.""" + with pytest.raises(StatelessModeNotSupported) as exc_info: + await stateless_session.list_roots() + + assert exc_info.value.method == "list_roots" + + +@pytest.fixture +async def stateful_session() -> AsyncGenerator[ServerSession, None]: + """Create a stateful ServerSession for testing.""" + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage | Exception](1) + + init_options = InitializationOptions( + server_name="test", + server_version="0.1.0", + capabilities=ServerCapabilities(), + ) + + async with ( + client_to_server_send, + client_to_server_receive, + server_to_client_send, + server_to_client_receive, + ): + async with ServerSession( + client_to_server_receive, + server_to_client_send, + init_options, + stateless=False, + ) as session: + yield session + + +@pytest.mark.anyio +async def test_stateful_mode_does_not_raise_stateless_error( + stateful_session: ServerSession, monkeypatch: pytest.MonkeyPatch +): + """Test that StatelessModeNotSupported is not raised in stateful mode. + + We mock send_request to avoid blocking on I/O while still verifying + that the stateless check passes. + """ + send_request_called = False + + async def mock_send_request(*_: Any, **__: Any) -> types.ListRootsResult: + nonlocal send_request_called + send_request_called = True + return types.ListRootsResult(roots=[]) + + monkeypatch.setattr(stateful_session, "send_request", mock_send_request) + + # This should NOT raise StatelessModeNotSupported + result = await stateful_session.list_roots() + + assert send_request_called + assert isinstance(result, types.ListRootsResult) diff --git a/tests/server/test_stdio.py b/tests/server/test_stdio.py index 13cdde3d6..9a7ddaab4 100644 --- a/tests/server/test_stdio.py +++ b/tests/server/test_stdio.py @@ -5,7 +5,7 @@ from mcp.server.stdio import stdio_server from mcp.shared.message import SessionMessage -from mcp.types import JSONRPCMessage, JSONRPCRequest, JSONRPCResponse +from mcp.types import JSONRPCMessage, JSONRPCRequest, JSONRPCResponse, jsonrpc_message_adapter @pytest.mark.anyio @@ -14,8 +14,8 @@ async def test_stdio_server(): stdout = io.StringIO() messages = [ - JSONRPCMessage(root=JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")), - JSONRPCMessage(root=JSONRPCResponse(jsonrpc="2.0", id=2, result={})), + JSONRPCRequest(jsonrpc="2.0", id=1, method="ping"), + JSONRPCResponse(jsonrpc="2.0", id=2, result={}), ] for message in messages: @@ -37,13 +37,13 @@ async def test_stdio_server(): # Verify received messages assert len(received_messages) == 2 - assert received_messages[0] == JSONRPCMessage(root=JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")) - assert received_messages[1] == JSONRPCMessage(root=JSONRPCResponse(jsonrpc="2.0", id=2, result={})) + assert received_messages[0] == JSONRPCRequest(jsonrpc="2.0", id=1, method="ping") + assert received_messages[1] == JSONRPCResponse(jsonrpc="2.0", id=2, result={}) # Test sending responses from the server responses = [ - JSONRPCMessage(root=JSONRPCRequest(jsonrpc="2.0", id=3, method="ping")), - JSONRPCMessage(root=JSONRPCResponse(jsonrpc="2.0", id=4, result={})), + JSONRPCRequest(jsonrpc="2.0", id=3, method="ping"), + JSONRPCResponse(jsonrpc="2.0", id=4, result={}), ] async with write_stream: @@ -55,7 +55,7 @@ async def test_stdio_server(): output_lines = stdout.readlines() assert len(output_lines) == 2 - received_responses = [JSONRPCMessage.model_validate_json(line.strip()) for line in output_lines] + received_responses = [jsonrpc_message_adapter.validate_json(line.strip()) for line in output_lines] assert len(received_responses) == 2 - assert received_responses[0] == JSONRPCMessage(root=JSONRPCRequest(jsonrpc="2.0", id=3, method="ping")) - assert received_responses[1] == JSONRPCMessage(root=JSONRPCResponse(jsonrpc="2.0", id=4, result={})) + assert received_responses[0] == JSONRPCRequest(jsonrpc="2.0", id=3, method="ping") + assert received_responses[1] == JSONRPCResponse(jsonrpc="2.0", id=4, result={}) diff --git a/tests/server/test_streamable_http_manager.py b/tests/server/test_streamable_http_manager.py index 6fcf08aa0..54a898cc5 100644 --- a/tests/server/test_streamable_http_manager.py +++ b/tests/server/test_streamable_http_manager.py @@ -1,16 +1,20 @@ """Tests for StreamableHTTPSessionManager.""" +import json from typing import Any from unittest.mock import AsyncMock, patch import anyio +import httpx import pytest from starlette.types import Message -from mcp.server import streamable_http_manager -from mcp.server.lowlevel import Server +from mcp import Client +from mcp.client.streamable_http import streamable_http_client +from mcp.server import Server, ServerRequestContext, streamable_http_manager from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, StreamableHTTPServerTransport from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from mcp.types import INVALID_REQUEST, ListToolsResult, PaginatedRequestParams @pytest.mark.anyio @@ -213,7 +217,7 @@ async def test_stateless_requests_memory_cleanup(): # Patch StreamableHTTPServerTransport constructor to track instances - original_constructor = streamable_http_manager.StreamableHTTPServerTransport + original_constructor = StreamableHTTPServerTransport def track_transport(*args: Any, **kwargs: Any) -> StreamableHTTPServerTransport: transport = original_constructor(*args, **kwargs) @@ -262,3 +266,147 @@ async def mock_receive(): # Verify internal state is cleaned up assert len(transport._request_streams) == 0, "Transport should have no active request streams" + + +@pytest.mark.anyio +async def test_unknown_session_id_returns_404(): + """Test that requests with unknown session IDs return HTTP 404 per MCP spec.""" + app = Server("test-unknown-session") + manager = StreamableHTTPSessionManager(app=app) + + async with manager.run(): + sent_messages: list[Message] = [] + response_body = b"" + + async def mock_send(message: Message): + nonlocal response_body + sent_messages.append(message) + if message["type"] == "http.response.body": + response_body += message.get("body", b"") + + # Request with a non-existent session ID + scope = { + "type": "http", + "method": "POST", + "path": "/mcp", + "headers": [ + (b"content-type", b"application/json"), + (b"accept", b"application/json, text/event-stream"), + (b"mcp-session-id", b"non-existent-session-id"), + ], + } + + async def mock_receive(): + return {"type": "http.request", "body": b"{}", "more_body": False} # pragma: no cover + + await manager.handle_request(scope, mock_receive, mock_send) + + # Find the response start message + response_start = next( + (msg for msg in sent_messages if msg["type"] == "http.response.start"), + None, + ) + assert response_start is not None, "Should have sent a response" + assert response_start["status"] == 404, "Should return HTTP 404 for unknown session ID" + + # Verify JSON-RPC error format + error_data = json.loads(response_body) + assert error_data["jsonrpc"] == "2.0" + assert error_data["id"] is None + assert error_data["error"]["code"] == INVALID_REQUEST + assert error_data["error"]["message"] == "Session not found" + + +@pytest.mark.anyio +async def test_e2e_streamable_http_server_cleanup(): + host = "testserver" + + async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[]) + + app = Server("test-server", on_list_tools=handle_list_tools) + mcp_app = app.streamable_http_app(host=host) + async with ( + mcp_app.router.lifespan_context(mcp_app), + httpx.ASGITransport(mcp_app) as transport, + httpx.AsyncClient(transport=transport) as http_client, + Client(streamable_http_client(f"http://{host}/mcp", http_client=http_client)) as client, + ): + await client.list_tools() + + +@pytest.mark.anyio +async def test_idle_session_is_reaped(): + """After idle timeout fires, the session returns 404.""" + app = Server("test-idle-reap") + manager = StreamableHTTPSessionManager(app=app, session_idle_timeout=0.05) + + async with manager.run(): + sent_messages: list[Message] = [] + + async def mock_send(message: Message): + sent_messages.append(message) + + scope = { + "type": "http", + "method": "POST", + "path": "/mcp", + "headers": [(b"content-type", b"application/json")], + } + + async def mock_receive(): # pragma: no cover + return {"type": "http.request", "body": b"", "more_body": False} + + await manager.handle_request(scope, mock_receive, mock_send) + + session_id = None + for msg in sent_messages: # pragma: no branch + if msg["type"] == "http.response.start": # pragma: no branch + for header_name, header_value in msg.get("headers", []): # pragma: no branch + if header_name.decode().lower() == MCP_SESSION_ID_HEADER.lower(): + session_id = header_value.decode() + break + if session_id: # pragma: no branch + break + + assert session_id is not None, "Session ID not found in response headers" + + # Wait for the 50ms idle timeout to fire and cleanup to complete + await anyio.sleep(0.1) + + # Verify via public API: old session ID now returns 404 + response_messages: list[Message] = [] + + async def capture_send(message: Message): + response_messages.append(message) + + scope_with_session = { + "type": "http", + "method": "POST", + "path": "/mcp", + "headers": [ + (b"content-type", b"application/json"), + (b"mcp-session-id", session_id.encode()), + ], + } + + await manager.handle_request(scope_with_session, mock_receive, capture_send) + + response_start = next( + (msg for msg in response_messages if msg["type"] == "http.response.start"), + None, + ) + assert response_start is not None + assert response_start["status"] == 404 + + +def test_session_idle_timeout_rejects_non_positive(): + with pytest.raises(ValueError, match="positive number"): + StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=-1) + with pytest.raises(ValueError, match="positive number"): + StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=0) + + +def test_session_idle_timeout_rejects_stateless(): + with pytest.raises(RuntimeError, match="not supported in stateless"): + StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=30, stateless=True) diff --git a/tests/server/test_streamable_http_security.py b/tests/server/test_streamable_http_security.py index a637b1dce..897555353 100644 --- a/tests/server/test_streamable_http_security.py +++ b/tests/server/test_streamable_http_security.py @@ -1,6 +1,5 @@ """Tests for StreamableHTTP server DNS rebinding protection.""" -import logging import multiprocessing import socket from collections.abc import AsyncGenerator @@ -19,7 +18,6 @@ from mcp.types import Tool from tests.test_helpers import wait_for_server -logger = logging.getLogger(__name__) SERVER_NAME = "test_streamable_http_security_server" diff --git a/tests/server/test_validation.py b/tests/server/test_validation.py index 56044460d..ad97dd3fd 100644 --- a/tests/server/test_validation.py +++ b/tests/server/test_validation.py @@ -7,7 +7,7 @@ validate_sampling_tools, validate_tool_use_result_messages, ) -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.types import ( ClientCapabilities, SamplingCapability, @@ -20,122 +20,132 @@ ToolUseContent, ) +# Tests for check_sampling_tools_capability function -class TestCheckSamplingToolsCapability: - """Tests for check_sampling_tools_capability function.""" - - def test_returns_false_when_caps_none(self) -> None: - """Returns False when client_caps is None.""" - assert check_sampling_tools_capability(None) is False - - def test_returns_false_when_sampling_none(self) -> None: - """Returns False when client_caps.sampling is None.""" - caps = ClientCapabilities() - assert check_sampling_tools_capability(caps) is False - - def test_returns_false_when_tools_none(self) -> None: - """Returns False when client_caps.sampling.tools is None.""" - caps = ClientCapabilities(sampling=SamplingCapability()) - assert check_sampling_tools_capability(caps) is False - - def test_returns_true_when_tools_present(self) -> None: - """Returns True when sampling.tools is present.""" - caps = ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability())) - assert check_sampling_tools_capability(caps) is True - - -class TestValidateSamplingTools: - """Tests for validate_sampling_tools function.""" - - def test_no_error_when_tools_none(self) -> None: - """No error when tools and tool_choice are None.""" - validate_sampling_tools(None, None, None) # Should not raise - - def test_raises_when_tools_provided_but_no_capability(self) -> None: - """Raises McpError when tools provided but client doesn't support.""" - tool = Tool(name="test", inputSchema={"type": "object"}) - with pytest.raises(McpError) as exc_info: - validate_sampling_tools(None, [tool], None) - assert "sampling tools capability" in str(exc_info.value) - - def test_raises_when_tool_choice_provided_but_no_capability(self) -> None: - """Raises McpError when tool_choice provided but client doesn't support.""" - with pytest.raises(McpError) as exc_info: - validate_sampling_tools(None, None, ToolChoice(mode="auto")) - assert "sampling tools capability" in str(exc_info.value) - - def test_no_error_when_capability_present(self) -> None: - """No error when client has sampling.tools capability.""" - caps = ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability())) - tool = Tool(name="test", inputSchema={"type": "object"}) - validate_sampling_tools(caps, [tool], ToolChoice(mode="auto")) # Should not raise - - -class TestValidateToolUseResultMessages: - """Tests for validate_tool_use_result_messages function.""" - - def test_no_error_for_empty_messages(self) -> None: - """No error when messages list is empty.""" - validate_tool_use_result_messages([]) # Should not raise - - def test_no_error_for_simple_text_messages(self) -> None: - """No error for simple text messages.""" - messages = [ - SamplingMessage(role="user", content=TextContent(type="text", text="Hello")), - SamplingMessage(role="assistant", content=TextContent(type="text", text="Hi")), - ] - validate_tool_use_result_messages(messages) # Should not raise - - def test_raises_when_tool_result_mixed_with_other_content(self) -> None: - """Raises when tool_result is mixed with other content types.""" - messages = [ - SamplingMessage( - role="user", - content=[ - ToolResultContent(type="tool_result", toolUseId="123"), - TextContent(type="text", text="also this"), - ], - ), - ] - with pytest.raises(ValueError, match="only tool_result content"): - validate_tool_use_result_messages(messages) - - def test_raises_when_tool_result_without_previous_tool_use(self) -> None: - """Raises when tool_result appears without preceding tool_use.""" - messages = [ - SamplingMessage( - role="user", - content=ToolResultContent(type="tool_result", toolUseId="123"), - ), - ] - with pytest.raises(ValueError, match="previous message containing tool_use"): - validate_tool_use_result_messages(messages) - - def test_raises_when_tool_result_ids_dont_match_tool_use(self) -> None: - """Raises when tool_result IDs don't match tool_use IDs.""" - messages = [ - SamplingMessage( - role="assistant", - content=ToolUseContent(type="tool_use", id="tool-1", name="test", input={}), - ), - SamplingMessage( - role="user", - content=ToolResultContent(type="tool_result", toolUseId="tool-2"), - ), - ] - with pytest.raises(ValueError, match="do not match"): - validate_tool_use_result_messages(messages) - - def test_no_error_when_tool_result_matches_tool_use(self) -> None: - """No error when tool_result IDs match tool_use IDs.""" - messages = [ - SamplingMessage( - role="assistant", - content=ToolUseContent(type="tool_use", id="tool-1", name="test", input={}), - ), - SamplingMessage( - role="user", - content=ToolResultContent(type="tool_result", toolUseId="tool-1"), - ), - ] - validate_tool_use_result_messages(messages) # Should not raise + +def test_check_sampling_tools_capability_returns_false_when_caps_none() -> None: + """Returns False when client_caps is None.""" + assert check_sampling_tools_capability(None) is False + + +def test_check_sampling_tools_capability_returns_false_when_sampling_none() -> None: + """Returns False when client_caps.sampling is None.""" + caps = ClientCapabilities() + assert check_sampling_tools_capability(caps) is False + + +def test_check_sampling_tools_capability_returns_false_when_tools_none() -> None: + """Returns False when client_caps.sampling.tools is None.""" + caps = ClientCapabilities(sampling=SamplingCapability()) + assert check_sampling_tools_capability(caps) is False + + +def test_check_sampling_tools_capability_returns_true_when_tools_present() -> None: + """Returns True when sampling.tools is present.""" + caps = ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability())) + assert check_sampling_tools_capability(caps) is True + + +# Tests for validate_sampling_tools function + + +def test_validate_sampling_tools_no_error_when_tools_none() -> None: + """No error when tools and tool_choice are None.""" + validate_sampling_tools(None, None, None) # Should not raise + + +def test_validate_sampling_tools_raises_when_tools_provided_but_no_capability() -> None: + """Raises MCPError when tools provided but client doesn't support.""" + tool = Tool(name="test", input_schema={"type": "object"}) + with pytest.raises(MCPError) as exc_info: + validate_sampling_tools(None, [tool], None) + assert "sampling tools capability" in str(exc_info.value) + + +def test_validate_sampling_tools_raises_when_tool_choice_provided_but_no_capability() -> None: + """Raises MCPError when tool_choice provided but client doesn't support.""" + with pytest.raises(MCPError) as exc_info: + validate_sampling_tools(None, None, ToolChoice(mode="auto")) + assert "sampling tools capability" in str(exc_info.value) + + +def test_validate_sampling_tools_no_error_when_capability_present() -> None: + """No error when client has sampling.tools capability.""" + caps = ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability())) + tool = Tool(name="test", input_schema={"type": "object"}) + validate_sampling_tools(caps, [tool], ToolChoice(mode="auto")) # Should not raise + + +# Tests for validate_tool_use_result_messages function + + +def test_validate_tool_use_result_messages_no_error_for_empty_messages() -> None: + """No error when messages list is empty.""" + validate_tool_use_result_messages([]) # Should not raise + + +def test_validate_tool_use_result_messages_no_error_for_simple_text_messages() -> None: + """No error for simple text messages.""" + messages = [ + SamplingMessage(role="user", content=TextContent(type="text", text="Hello")), + SamplingMessage(role="assistant", content=TextContent(type="text", text="Hi")), + ] + validate_tool_use_result_messages(messages) # Should not raise + + +def test_validate_tool_use_result_messages_raises_when_tool_result_mixed_with_other_content() -> None: + """Raises when tool_result is mixed with other content types.""" + messages = [ + SamplingMessage( + role="user", + content=[ + ToolResultContent(type="tool_result", tool_use_id="123"), + TextContent(type="text", text="also this"), + ], + ), + ] + with pytest.raises(ValueError, match="only tool_result content"): + validate_tool_use_result_messages(messages) + + +def test_validate_tool_use_result_messages_raises_when_tool_result_without_previous_tool_use() -> None: + """Raises when tool_result appears without preceding tool_use.""" + messages = [ + SamplingMessage( + role="user", + content=ToolResultContent(type="tool_result", tool_use_id="123"), + ), + ] + with pytest.raises(ValueError, match="previous message containing tool_use"): + validate_tool_use_result_messages(messages) + + +def test_validate_tool_use_result_messages_raises_when_tool_result_ids_dont_match_tool_use() -> None: + """Raises when tool_result IDs don't match tool_use IDs.""" + messages = [ + SamplingMessage( + role="assistant", + content=ToolUseContent(type="tool_use", id="tool-1", name="test", input={}), + ), + SamplingMessage( + role="user", + content=ToolResultContent(type="tool_result", tool_use_id="tool-2"), + ), + ] + with pytest.raises(ValueError, match="do not match"): + validate_tool_use_result_messages(messages) + + +def test_validate_tool_use_result_messages_no_error_when_tool_result_matches_tool_use() -> None: + """No error when tool_result IDs match tool_use IDs.""" + messages = [ + SamplingMessage( + role="assistant", + content=ToolUseContent(type="tool_use", id="tool-1", name="test", input={}), + ), + SamplingMessage( + role="user", + content=ToolResultContent(type="tool_result", tool_use_id="tool-1"), + ), + ] + validate_tool_use_result_messages(messages) # Should not raise diff --git a/tests/shared/__init__.py b/tests/shared/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/shared/test_auth.py b/tests/shared/test_auth.py index bd9f5a934..cd3c35332 100644 --- a/tests/shared/test_auth.py +++ b/tests/shared/test_auth.py @@ -3,59 +3,58 @@ from mcp.shared.auth import OAuthMetadata -class TestOAuthMetadata: - """Tests for OAuthMetadata parsing.""" +def test_oauth(): + """Should not throw when parsing OAuth metadata.""" + OAuthMetadata.model_validate( + { + "issuer": "https://example.com", + "authorization_endpoint": "https://example.com/oauth2/authorize", + "token_endpoint": "https://example.com/oauth2/token", + "scopes_supported": ["read", "write"], + "response_types_supported": ["code", "token"], + "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"], + } + ) - def test_oauth(self): - """Should not throw when parsing OAuth metadata.""" - OAuthMetadata.model_validate( - { - "issuer": "https://example.com", - "authorization_endpoint": "https://example.com/oauth2/authorize", - "token_endpoint": "https://example.com/oauth2/token", - "scopes_supported": ["read", "write"], - "response_types_supported": ["code", "token"], - "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"], - } - ) - def test_oidc(self): - """Should not throw when parsing OIDC metadata.""" - OAuthMetadata.model_validate( - { - "issuer": "https://example.com", - "authorization_endpoint": "https://example.com/oauth2/authorize", - "token_endpoint": "https://example.com/oauth2/token", - "end_session_endpoint": "https://example.com/logout", - "id_token_signing_alg_values_supported": ["RS256"], - "jwks_uri": "https://example.com/.well-known/jwks.json", - "response_types_supported": ["code", "token"], - "revocation_endpoint": "https://example.com/oauth2/revoke", - "scopes_supported": ["openid", "read", "write"], - "subject_types_supported": ["public"], - "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"], - "userinfo_endpoint": "https://example.com/oauth2/userInfo", - } - ) +def test_oidc(): + """Should not throw when parsing OIDC metadata.""" + OAuthMetadata.model_validate( + { + "issuer": "https://example.com", + "authorization_endpoint": "https://example.com/oauth2/authorize", + "token_endpoint": "https://example.com/oauth2/token", + "end_session_endpoint": "https://example.com/logout", + "id_token_signing_alg_values_supported": ["RS256"], + "jwks_uri": "https://example.com/.well-known/jwks.json", + "response_types_supported": ["code", "token"], + "revocation_endpoint": "https://example.com/oauth2/revoke", + "scopes_supported": ["openid", "read", "write"], + "subject_types_supported": ["public"], + "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"], + "userinfo_endpoint": "https://example.com/oauth2/userInfo", + } + ) - def test_oauth_with_jarm(self): - """Should not throw when parsing OAuth metadata that includes JARM response modes.""" - OAuthMetadata.model_validate( - { - "issuer": "https://example.com", - "authorization_endpoint": "https://example.com/oauth2/authorize", - "token_endpoint": "https://example.com/oauth2/token", - "scopes_supported": ["read", "write"], - "response_types_supported": ["code", "token"], - "response_modes_supported": [ - "query", - "fragment", - "form_post", - "query.jwt", - "fragment.jwt", - "form_post.jwt", - "jwt", - ], - "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"], - } - ) + +def test_oauth_with_jarm(): + """Should not throw when parsing OAuth metadata that includes JARM response modes.""" + OAuthMetadata.model_validate( + { + "issuer": "https://example.com", + "authorization_endpoint": "https://example.com/oauth2/authorize", + "token_endpoint": "https://example.com/oauth2/token", + "scopes_supported": ["read", "write"], + "response_types_supported": ["code", "token"], + "response_modes_supported": [ + "query", + "fragment", + "form_post", + "query.jwt", + "fragment.jwt", + "form_post.jwt", + "jwt", + ], + "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"], + } + ) diff --git a/tests/shared/test_auth_utils.py b/tests/shared/test_auth_utils.py index 5b12dc677..5ae0e22b0 100644 --- a/tests/shared/test_auth_utils.py +++ b/tests/shared/test_auth_utils.py @@ -1,112 +1,123 @@ """Tests for OAuth 2.0 Resource Indicators utilities.""" +from pydantic import HttpUrl + from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url +# Tests for resource_url_from_server_url function + + +def test_resource_url_from_server_url_removes_fragment(): + """Fragment should be removed per RFC 8707.""" + assert resource_url_from_server_url("https://example.com/path#fragment") == "https://example.com/path" + assert resource_url_from_server_url("https://example.com/#fragment") == "https://example.com/" + + +def test_resource_url_from_server_url_preserves_path(): + """Path should be preserved.""" + assert ( + resource_url_from_server_url("https://example.com/path/to/resource") == "https://example.com/path/to/resource" + ) + assert resource_url_from_server_url("https://example.com/") == "https://example.com/" + assert resource_url_from_server_url("https://example.com") == "https://example.com" + + +def test_resource_url_from_server_url_preserves_query(): + """Query parameters should be preserved.""" + assert resource_url_from_server_url("https://example.com/path?foo=bar") == "https://example.com/path?foo=bar" + assert resource_url_from_server_url("https://example.com/?key=value") == "https://example.com/?key=value" + + +def test_resource_url_from_server_url_preserves_port(): + """Non-default ports should be preserved.""" + assert resource_url_from_server_url("https://example.com:8443/path") == "https://example.com:8443/path" + assert resource_url_from_server_url("http://example.com:8080/") == "http://example.com:8080/" + + +def test_resource_url_from_server_url_lowercase_scheme_and_host(): + """Scheme and host should be lowercase for canonical form.""" + assert resource_url_from_server_url("HTTPS://EXAMPLE.COM/path") == "https://example.com/path" + assert resource_url_from_server_url("Http://Example.Com:8080/") == "http://example.com:8080/" + + +def test_resource_url_from_server_url_handles_pydantic_urls(): + """Should handle Pydantic URL types.""" + url = HttpUrl("https://example.com/path") + assert resource_url_from_server_url(url) == "https://example.com/path" + + +# Tests for check_resource_allowed function + + +def test_check_resource_allowed_identical_urls(): + """Identical URLs should match.""" + assert check_resource_allowed("https://example.com/path", "https://example.com/path") is True + assert check_resource_allowed("https://example.com/", "https://example.com/") is True + assert check_resource_allowed("https://example.com", "https://example.com") is True + + +def test_check_resource_allowed_different_schemes(): + """Different schemes should not match.""" + assert check_resource_allowed("https://example.com/path", "http://example.com/path") is False + assert check_resource_allowed("http://example.com/", "https://example.com/") is False + + +def test_check_resource_allowed_different_domains(): + """Different domains should not match.""" + assert check_resource_allowed("https://example.com/path", "https://example.org/path") is False + assert check_resource_allowed("https://sub.example.com/", "https://example.com/") is False + + +def test_check_resource_allowed_different_ports(): + """Different ports should not match.""" + assert check_resource_allowed("https://example.com:8443/path", "https://example.com/path") is False + assert check_resource_allowed("https://example.com:8080/", "https://example.com:8443/") is False + + +def test_check_resource_allowed_hierarchical_matching(): + """Child paths should match parent paths.""" + # Parent resource allows child resources + assert check_resource_allowed("https://example.com/api/v1/users", "https://example.com/api") is True + assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api") is True + assert check_resource_allowed("https://example.com/mcp/server", "https://example.com/mcp") is True + + # Exact match + assert check_resource_allowed("https://example.com/api", "https://example.com/api") is True + + # Parent cannot use child's token + assert check_resource_allowed("https://example.com/api", "https://example.com/api/v1") is False + assert check_resource_allowed("https://example.com/", "https://example.com/api") is False + + +def test_check_resource_allowed_path_boundary_matching(): + """Path matching should respect boundaries.""" + # Should not match partial path segments + assert check_resource_allowed("https://example.com/apiextra", "https://example.com/api") is False + assert check_resource_allowed("https://example.com/api123", "https://example.com/api") is False + + # Should match with trailing slash + assert check_resource_allowed("https://example.com/api/", "https://example.com/api") is True + assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api/") is True + + +def test_check_resource_allowed_trailing_slash_handling(): + """Trailing slashes should be handled correctly.""" + # With and without trailing slashes + assert check_resource_allowed("https://example.com/api/", "https://example.com/api") is True + assert check_resource_allowed("https://example.com/api", "https://example.com/api/") is True + assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api") is True + assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api/") is True + + +def test_check_resource_allowed_case_insensitive_origin(): + """Origin comparison should be case-insensitive.""" + assert check_resource_allowed("https://EXAMPLE.COM/path", "https://example.com/path") is True + assert check_resource_allowed("HTTPS://example.com/path", "https://example.com/path") is True + assert check_resource_allowed("https://Example.Com:8080/api", "https://example.com:8080/api") is True + -class TestResourceUrlFromServerUrl: - """Tests for resource_url_from_server_url function.""" - - def test_removes_fragment(self): - """Fragment should be removed per RFC 8707.""" - assert resource_url_from_server_url("https://example.com/path#fragment") == "https://example.com/path" - assert resource_url_from_server_url("https://example.com/#fragment") == "https://example.com/" - - def test_preserves_path(self): - """Path should be preserved.""" - assert ( - resource_url_from_server_url("https://example.com/path/to/resource") - == "https://example.com/path/to/resource" - ) - assert resource_url_from_server_url("https://example.com/") == "https://example.com/" - assert resource_url_from_server_url("https://example.com") == "https://example.com" - - def test_preserves_query(self): - """Query parameters should be preserved.""" - assert resource_url_from_server_url("https://example.com/path?foo=bar") == "https://example.com/path?foo=bar" - assert resource_url_from_server_url("https://example.com/?key=value") == "https://example.com/?key=value" - - def test_preserves_port(self): - """Non-default ports should be preserved.""" - assert resource_url_from_server_url("https://example.com:8443/path") == "https://example.com:8443/path" - assert resource_url_from_server_url("http://example.com:8080/") == "http://example.com:8080/" - - def test_lowercase_scheme_and_host(self): - """Scheme and host should be lowercase for canonical form.""" - assert resource_url_from_server_url("HTTPS://EXAMPLE.COM/path") == "https://example.com/path" - assert resource_url_from_server_url("Http://Example.Com:8080/") == "http://example.com:8080/" - - def test_handles_pydantic_urls(self): - """Should handle Pydantic URL types.""" - from pydantic import HttpUrl - - url = HttpUrl("https://example.com/path") - assert resource_url_from_server_url(url) == "https://example.com/path" - - -class TestCheckResourceAllowed: - """Tests for check_resource_allowed function.""" - - def test_identical_urls(self): - """Identical URLs should match.""" - assert check_resource_allowed("https://example.com/path", "https://example.com/path") is True - assert check_resource_allowed("https://example.com/", "https://example.com/") is True - assert check_resource_allowed("https://example.com", "https://example.com") is True - - def test_different_schemes(self): - """Different schemes should not match.""" - assert check_resource_allowed("https://example.com/path", "http://example.com/path") is False - assert check_resource_allowed("http://example.com/", "https://example.com/") is False - - def test_different_domains(self): - """Different domains should not match.""" - assert check_resource_allowed("https://example.com/path", "https://example.org/path") is False - assert check_resource_allowed("https://sub.example.com/", "https://example.com/") is False - - def test_different_ports(self): - """Different ports should not match.""" - assert check_resource_allowed("https://example.com:8443/path", "https://example.com/path") is False - assert check_resource_allowed("https://example.com:8080/", "https://example.com:8443/") is False - - def test_hierarchical_matching(self): - """Child paths should match parent paths.""" - # Parent resource allows child resources - assert check_resource_allowed("https://example.com/api/v1/users", "https://example.com/api") is True - assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api") is True - assert check_resource_allowed("https://example.com/mcp/server", "https://example.com/mcp") is True - - # Exact match - assert check_resource_allowed("https://example.com/api", "https://example.com/api") is True - - # Parent cannot use child's token - assert check_resource_allowed("https://example.com/api", "https://example.com/api/v1") is False - assert check_resource_allowed("https://example.com/", "https://example.com/api") is False - - def test_path_boundary_matching(self): - """Path matching should respect boundaries.""" - # Should not match partial path segments - assert check_resource_allowed("https://example.com/apiextra", "https://example.com/api") is False - assert check_resource_allowed("https://example.com/api123", "https://example.com/api") is False - - # Should match with trailing slash - assert check_resource_allowed("https://example.com/api/", "https://example.com/api") is True - assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api/") is True - - def test_trailing_slash_handling(self): - """Trailing slashes should be handled correctly.""" - # With and without trailing slashes - assert check_resource_allowed("https://example.com/api/", "https://example.com/api") is True - assert check_resource_allowed("https://example.com/api", "https://example.com/api/") is False - assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api") is True - assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api/") is True - - def test_case_insensitive_origin(self): - """Origin comparison should be case-insensitive.""" - assert check_resource_allowed("https://EXAMPLE.COM/path", "https://example.com/path") is True - assert check_resource_allowed("HTTPS://example.com/path", "https://example.com/path") is True - assert check_resource_allowed("https://Example.Com:8080/api", "https://example.com:8080/api") is True - - def test_empty_paths(self): - """Empty paths should be handled correctly.""" - assert check_resource_allowed("https://example.com", "https://example.com") is True - assert check_resource_allowed("https://example.com/", "https://example.com") is True - assert check_resource_allowed("https://example.com/api", "https://example.com") is True +def test_check_resource_allowed_empty_paths(): + """Empty paths should be handled correctly.""" + assert check_resource_allowed("https://example.com", "https://example.com") is True + assert check_resource_allowed("https://example.com/", "https://example.com") is True + assert check_resource_allowed("https://example.com/api", "https://example.com") is True diff --git a/tests/shared/test_exceptions.py b/tests/shared/test_exceptions.py index 8845dfe78..9a7466264 100644 --- a/tests/shared/test_exceptions.py +++ b/tests/shared/test_exceptions.py @@ -2,158 +2,163 @@ import pytest -from mcp.shared.exceptions import McpError, UrlElicitationRequiredError +from mcp.shared.exceptions import MCPError, UrlElicitationRequiredError from mcp.types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams, ErrorData -class TestUrlElicitationRequiredError: - """Tests for UrlElicitationRequiredError exception class.""" +def test_url_elicitation_required_error_create_with_single_elicitation() -> None: + """Test creating error with a single elicitation.""" + elicitation = ElicitRequestURLParams( + mode="url", + message="Auth required", + url="https://example.com/auth", + elicitation_id="test-123", + ) + error = UrlElicitationRequiredError([elicitation]) - def test_create_with_single_elicitation(self) -> None: - """Test creating error with a single elicitation.""" - elicitation = ElicitRequestURLParams( - mode="url", - message="Auth required", - url="https://example.com/auth", - elicitationId="test-123", - ) - error = UrlElicitationRequiredError([elicitation]) - - assert error.error.code == URL_ELICITATION_REQUIRED - assert error.error.message == "URL elicitation required" - assert len(error.elicitations) == 1 - assert error.elicitations[0].elicitationId == "test-123" - - def test_create_with_multiple_elicitations(self) -> None: - """Test creating error with multiple elicitations uses plural message.""" - elicitations = [ - ElicitRequestURLParams( - mode="url", - message="Auth 1", - url="https://example.com/auth1", - elicitationId="test-1", - ), - ElicitRequestURLParams( - mode="url", - message="Auth 2", - url="https://example.com/auth2", - elicitationId="test-2", - ), - ] - error = UrlElicitationRequiredError(elicitations) + assert error.error.code == URL_ELICITATION_REQUIRED + assert error.error.message == "URL elicitation required" + assert len(error.elicitations) == 1 + assert error.elicitations[0].elicitation_id == "test-123" - assert error.error.message == "URL elicitations required" # Plural - assert len(error.elicitations) == 2 - def test_custom_message(self) -> None: - """Test creating error with a custom message.""" - elicitation = ElicitRequestURLParams( +def test_url_elicitation_required_error_create_with_multiple_elicitations() -> None: + """Test creating error with multiple elicitations uses plural message.""" + elicitations = [ + ElicitRequestURLParams( mode="url", - message="Auth required", - url="https://example.com/auth", - elicitationId="test-123", - ) - error = UrlElicitationRequiredError([elicitation], message="Custom message") - - assert error.error.message == "Custom message" - - def test_from_error_data(self) -> None: - """Test reconstructing error from ErrorData.""" - error_data = ErrorData( - code=URL_ELICITATION_REQUIRED, - message="URL elicitation required", - data={ - "elicitations": [ - { - "mode": "url", - "message": "Auth required", - "url": "https://example.com/auth", - "elicitationId": "test-123", - } - ] - }, - ) - - error = UrlElicitationRequiredError.from_error(error_data) - - assert len(error.elicitations) == 1 - assert error.elicitations[0].elicitationId == "test-123" - assert error.elicitations[0].url == "https://example.com/auth" - - def test_from_error_data_wrong_code(self) -> None: - """Test that from_error raises ValueError for wrong error code.""" - error_data = ErrorData( - code=-32600, # Wrong code - message="Some other error", - data={}, - ) - - with pytest.raises(ValueError, match="Expected error code"): - UrlElicitationRequiredError.from_error(error_data) - - def test_serialization_roundtrip(self) -> None: - """Test that error can be serialized and reconstructed.""" - original = UrlElicitationRequiredError( - [ - ElicitRequestURLParams( - mode="url", - message="Auth required", - url="https://example.com/auth", - elicitationId="test-123", - ) + message="Auth 1", + url="https://example.com/auth1", + elicitation_id="test-1", + ), + ElicitRequestURLParams( + mode="url", + message="Auth 2", + url="https://example.com/auth2", + elicitation_id="test-2", + ), + ] + error = UrlElicitationRequiredError(elicitations) + + assert error.error.message == "URL elicitations required" # Plural + assert len(error.elicitations) == 2 + + +def test_url_elicitation_required_error_custom_message() -> None: + """Test creating error with a custom message.""" + elicitation = ElicitRequestURLParams( + mode="url", + message="Auth required", + url="https://example.com/auth", + elicitation_id="test-123", + ) + error = UrlElicitationRequiredError([elicitation], message="Custom message") + + assert error.error.message == "Custom message" + + +def test_url_elicitation_required_error_from_error_data() -> None: + """Test reconstructing error from ErrorData.""" + error_data = ErrorData( + code=URL_ELICITATION_REQUIRED, + message="URL elicitation required", + data={ + "elicitations": [ + { + "mode": "url", + "message": "Auth required", + "url": "https://example.com/auth", + "elicitationId": "test-123", + } ] - ) + }, + ) - # Simulate serialization over wire - error_data = original.error + error = UrlElicitationRequiredError.from_error(error_data) - # Reconstruct - reconstructed = UrlElicitationRequiredError.from_error(error_data) + assert len(error.elicitations) == 1 + assert error.elicitations[0].elicitation_id == "test-123" + assert error.elicitations[0].url == "https://example.com/auth" - assert reconstructed.elicitations[0].elicitationId == original.elicitations[0].elicitationId - assert reconstructed.elicitations[0].url == original.elicitations[0].url - assert reconstructed.elicitations[0].message == original.elicitations[0].message - def test_error_data_contains_elicitations(self) -> None: - """Test that error data contains properly serialized elicitations.""" - elicitation = ElicitRequestURLParams( - mode="url", - message="Please authenticate", - url="https://example.com/oauth", - elicitationId="oauth-flow-1", - ) - error = UrlElicitationRequiredError([elicitation]) - - assert error.error.data is not None - assert "elicitations" in error.error.data - elicit_data = error.error.data["elicitations"][0] - assert elicit_data["mode"] == "url" - assert elicit_data["message"] == "Please authenticate" - assert elicit_data["url"] == "https://example.com/oauth" - assert elicit_data["elicitationId"] == "oauth-flow-1" - - def test_inherits_from_mcp_error(self) -> None: - """Test that UrlElicitationRequiredError inherits from McpError.""" - elicitation = ElicitRequestURLParams( - mode="url", - message="Auth required", - url="https://example.com/auth", - elicitationId="test-123", - ) - error = UrlElicitationRequiredError([elicitation]) - - assert isinstance(error, McpError) - assert isinstance(error, Exception) - - def test_exception_message(self) -> None: - """Test that exception message is set correctly.""" - elicitation = ElicitRequestURLParams( - mode="url", - message="Auth required", - url="https://example.com/auth", - elicitationId="test-123", - ) - error = UrlElicitationRequiredError([elicitation]) - - # The exception's string representation should match the message - assert str(error) == "URL elicitation required" +def test_url_elicitation_required_error_from_error_data_wrong_code() -> None: + """Test that from_error raises ValueError for wrong error code.""" + error_data = ErrorData( + code=-32600, # Wrong code + message="Some other error", + data={}, + ) + + with pytest.raises(ValueError, match="Expected error code"): + UrlElicitationRequiredError.from_error(error_data) + + +def test_url_elicitation_required_error_serialization_roundtrip() -> None: + """Test that error can be serialized and reconstructed.""" + original = UrlElicitationRequiredError( + [ + ElicitRequestURLParams( + mode="url", + message="Auth required", + url="https://example.com/auth", + elicitation_id="test-123", + ) + ] + ) + + # Simulate serialization over wire + error_data = original.error + + # Reconstruct + reconstructed = UrlElicitationRequiredError.from_error(error_data) + + assert reconstructed.elicitations[0].elicitation_id == original.elicitations[0].elicitation_id + assert reconstructed.elicitations[0].url == original.elicitations[0].url + assert reconstructed.elicitations[0].message == original.elicitations[0].message + + +def test_url_elicitation_required_error_data_contains_elicitations() -> None: + """Test that error data contains properly serialized elicitations.""" + elicitation = ElicitRequestURLParams( + mode="url", + message="Please authenticate", + url="https://example.com/oauth", + elicitation_id="oauth-flow-1", + ) + error = UrlElicitationRequiredError([elicitation]) + + assert error.error.data is not None + assert "elicitations" in error.error.data + elicit_data = error.error.data["elicitations"][0] + assert elicit_data["mode"] == "url" + assert elicit_data["message"] == "Please authenticate" + assert elicit_data["url"] == "https://example.com/oauth" + assert elicit_data["elicitationId"] == "oauth-flow-1" + + +def test_url_elicitation_required_error_inherits_from_mcp_error() -> None: + """Test that UrlElicitationRequiredError inherits from MCPError.""" + elicitation = ElicitRequestURLParams( + mode="url", + message="Auth required", + url="https://example.com/auth", + elicitation_id="test-123", + ) + error = UrlElicitationRequiredError([elicitation]) + + assert isinstance(error, MCPError) + assert isinstance(error, Exception) + + +def test_url_elicitation_required_error_exception_message() -> None: + """Test that exception message is set correctly.""" + elicitation = ElicitRequestURLParams( + mode="url", + message="Auth required", + url="https://example.com/auth", + elicitation_id="test-123", + ) + error = UrlElicitationRequiredError([elicitation]) + + # The exception's string representation should match the message + assert str(error) == "URL elicitation required" diff --git a/tests/shared/test_memory.py b/tests/shared/test_memory.py deleted file mode 100644 index ca4368e9f..000000000 --- a/tests/shared/test_memory.py +++ /dev/null @@ -1,42 +0,0 @@ -import pytest -from pydantic import AnyUrl -from typing_extensions import AsyncGenerator - -from mcp.client.session import ClientSession -from mcp.server import Server -from mcp.shared.memory import create_connected_server_and_client_session -from mcp.types import EmptyResult, Resource - - -@pytest.fixture -def mcp_server() -> Server: - server = Server(name="test_server") - - @server.list_resources() - async def handle_list_resources(): # pragma: no cover - return [ - Resource( - uri=AnyUrl("memory://test"), - name="Test Resource", - description="A test resource", - ) - ] - - return server - - -@pytest.fixture -async def client_connected_to_server( - mcp_server: Server, -) -> AsyncGenerator[ClientSession, None]: - async with create_connected_server_and_client_session(mcp_server) as client_session: - yield client_session - - -@pytest.mark.anyio -async def test_memory_server_and_client_connection( - client_connected_to_server: ClientSession, -): - """Shows how a client and server can communicate over memory streams.""" - response = await client_connected_to_server.send_ping() - assert isinstance(response, EmptyResult) diff --git a/tests/shared/test_progress_notifications.py b/tests/shared/test_progress_notifications.py index 1552711d2..aad9e5d43 100644 --- a/tests/shared/test_progress_notifications.py +++ b/tests/shared/test_progress_notifications.py @@ -1,19 +1,17 @@ -from typing import Any, cast +from typing import Any from unittest.mock import patch import anyio import pytest -import mcp.types as types +from mcp import Client, types from mcp.client.session import ClientSession -from mcp.server import Server +from mcp.server import Server, ServerRequestContext from mcp.server.lowlevel import NotificationOptions from mcp.server.models import InitializationOptions from mcp.server.session import ServerSession -from mcp.shared.context import RequestContext -from mcp.shared.memory import create_connected_server_and_client_session -from mcp.shared.progress import progress -from mcp.shared.session import BaseSession, RequestResponder, SessionMessage +from mcp.shared.message import SessionMessage +from mcp.shared.session import RequestResponder @pytest.mark.anyio @@ -35,9 +33,6 @@ async def run_server(): capabilities=server.get_capabilities(NotificationOptions(), {}), ), ) as server_session: - global serv_sesh - - serv_sesh = server_session async for message in server_session.incoming_messages: try: await server._handle_message(message, server_session, {}) @@ -52,79 +47,73 @@ async def run_server(): server_progress_token = "server_token_123" client_progress_token = "client_token_456" - # Create a server with progress capability - server = Server(name="ProgressTestServer") - # Register progress handler - @server.progress_notification() - async def handle_progress( - progress_token: str | int, - progress: float, - total: float | None, - message: str | None, - ): + async def handle_progress(ctx: ServerRequestContext, params: types.ProgressNotificationParams) -> None: server_progress_updates.append( { - "token": progress_token, - "progress": progress, - "total": total, - "message": message, + "token": params.progress_token, + "progress": params.progress, + "total": params.total, + "message": params.message, } ) # Register list tool handler - @server.list_tools() - async def handle_list_tools() -> list[types.Tool]: - return [ - types.Tool( - name="test_tool", - description="A tool that sends progress notifications <o/", - inputSchema={}, - ) - ] + async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="test_tool", + description="A tool that sends progress notifications <o/", + input_schema={}, + ) + ] + ) # Register tool handler - @server.call_tool() - async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[types.TextContent]: + async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: # Make sure we received a progress token - if name == "test_tool": - if arguments and "_meta" in arguments: - progressToken = arguments["_meta"]["progressToken"] - - if not progressToken: # pragma: no cover - raise ValueError("Empty progress token received") - - if progressToken != client_progress_token: # pragma: no cover - raise ValueError("Server sending back incorrect progressToken") - - # Send progress notifications - await serv_sesh.send_progress_notification( - progress_token=progressToken, - progress=0.25, - total=1.0, - message="Server progress 25%", - ) + if params.name == "test_tool": + assert params.meta is not None + progress_token = params.meta.get("progress_token") + assert progress_token is not None + assert progress_token == client_progress_token + + # Send progress notifications using ctx.session + await ctx.session.send_progress_notification( + progress_token=progress_token, + progress=0.25, + total=1.0, + message="Server progress 25%", + ) - await serv_sesh.send_progress_notification( - progress_token=progressToken, - progress=0.5, - total=1.0, - message="Server progress 50%", - ) + await ctx.session.send_progress_notification( + progress_token=progress_token, + progress=0.5, + total=1.0, + message="Server progress 50%", + ) - await serv_sesh.send_progress_notification( - progress_token=progressToken, - progress=1.0, - total=1.0, - message="Server progress 100%", - ) + await ctx.session.send_progress_notification( + progress_token=progress_token, + progress=1.0, + total=1.0, + message="Server progress 100%", + ) - else: # pragma: no cover - raise ValueError("Progress token not sent.") + return types.CallToolResult(content=[types.TextContent(type="text", text="Tool executed successfully")]) - return [types.TextContent(type="text", text="Tool executed successfully")] + raise ValueError(f"Unknown tool: {params.name}") # pragma: no cover - raise ValueError(f"Unknown tool: {name}") # pragma: no cover + # Create a server with progress capability + server = Server( + name="ProgressTestServer", + on_progress=handle_progress, + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, + ) # Client message handler to store progress notifications async def handle_client_message( @@ -134,11 +123,11 @@ async def handle_client_message( raise message if isinstance(message, types.ServerNotification): # pragma: no branch - if isinstance(message.root, types.ProgressNotification): # pragma: no branch - params = message.root.params + if isinstance(message, types.ProgressNotification): # pragma: no branch + params = message.params client_progress_updates.append( { - "token": params.progressToken, + "token": params.progress_token, "progress": params.progress, "total": params.total, "message": params.message, @@ -164,7 +153,7 @@ async def handle_client_message( await client_session.list_tools() # Call test_tool with progress token - await client_session.call_tool("test_tool", {"_meta": {"progressToken": client_progress_token}}) + await client_session.call_tool("test_tool", meta={"progress_token": client_progress_token}) # Send progress notifications from client to server await client_session.send_progress_notification( @@ -207,123 +196,6 @@ async def handle_client_message( assert server_progress_updates[2]["progress"] == 1.0 -@pytest.mark.anyio -async def test_progress_context_manager(): - """Test client using progress context manager for sending progress notifications.""" - # Create memory streams for client/server - server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](5) - client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](5) - - # Track progress updates - server_progress_updates: list[dict[str, Any]] = [] - - server = Server(name="ProgressContextTestServer") - - progress_token = None - - # Register progress handler - @server.progress_notification() - async def handle_progress( - progress_token: str | int, - progress: float, - total: float | None, - message: str | None, - ): - server_progress_updates.append( - {"token": progress_token, "progress": progress, "total": total, "message": message} - ) - - # Run server session to receive progress updates - async def run_server(): - # Create a server session - async with ServerSession( - client_to_server_receive, - server_to_client_send, - InitializationOptions( - server_name="ProgressContextTestServer", - server_version="0.1.0", - capabilities=server.get_capabilities(NotificationOptions(), {}), - ), - ) as server_session: - async for message in server_session.incoming_messages: - try: - await server._handle_message(message, server_session, {}) - except Exception as e: # pragma: no cover - raise e - - # Client message handler - async def handle_client_message( - message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, - ) -> None: - if isinstance(message, Exception): # pragma: no cover - raise message - - # run client session - async with ( - ClientSession( - server_to_client_receive, - client_to_server_send, - message_handler=handle_client_message, - ) as client_session, - anyio.create_task_group() as tg, - ): - tg.start_soon(run_server) - - await client_session.initialize() - - progress_token = "client_token_456" - - # Create request context - meta = types.RequestParams.Meta(progressToken=progress_token) - request_context = RequestContext( - request_id="test-request", - session=client_session, - meta=meta, - lifespan_context=None, - ) - - # cast for type checker - typed_context = cast(RequestContext[BaseSession[Any, Any, Any, Any, Any], Any], request_context) - - # Utilize progress context manager - with progress(typed_context, total=100) as p: - await p.progress(10, message="Loading configuration...") - await p.progress(30, message="Connecting to database...") - await p.progress(40, message="Fetching data...") - await p.progress(20, message="Processing results...") - - # Wait for all messages to be processed - await anyio.sleep(0.5) - tg.cancel_scope.cancel() - - # Verify progress updates were received by server - assert len(server_progress_updates) == 4 - - # first update - assert server_progress_updates[0]["token"] == progress_token - assert server_progress_updates[0]["progress"] == 10 - assert server_progress_updates[0]["total"] == 100 - assert server_progress_updates[0]["message"] == "Loading configuration..." - - # second update - assert server_progress_updates[1]["token"] == progress_token - assert server_progress_updates[1]["progress"] == 40 - assert server_progress_updates[1]["total"] == 100 - assert server_progress_updates[1]["message"] == "Connecting to database..." - - # third update - assert server_progress_updates[2]["token"] == progress_token - assert server_progress_updates[2]["progress"] == 80 - assert server_progress_updates[2]["total"] == 100 - assert server_progress_updates[2]["message"] == "Fetching data..." - - # final update - assert server_progress_updates[3]["token"] == progress_token - assert server_progress_updates[3]["progress"] == 100 - assert server_progress_updates[3]["total"] == 100 - assert server_progress_updates[3]["message"] == "Processing results..." - - @pytest.mark.anyio async def test_progress_callback_exception_logging(): """Test that exceptions in progress callbacks are logged and \ @@ -331,53 +203,53 @@ async def test_progress_callback_exception_logging(): # Track logged warnings logged_errors: list[str] = [] - def mock_log_error(msg: str, *args: Any) -> None: + def mock_log_exception(msg: str, *args: Any, **kwargs: Any) -> None: logged_errors.append(msg % args if args else msg) # Create a progress callback that raises an exception - async def failing_progress_callback( - progress: float, total: float | None, message: str | None - ) -> None: # pragma: no cover + async def failing_progress_callback(progress: float, total: float | None, message: str | None) -> None: raise ValueError("Progress callback failed!") # Create a server with a tool that sends progress notifications - server = Server(name="TestProgressServer") - - @server.call_tool() - async def handle_call_tool(name: str, arguments: Any) -> list[types.TextContent]: - if name == "progress_tool": + async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + if params.name == "progress_tool": + assert ctx.request_id is not None # Send a progress notification - await server.request_context.session.send_progress_notification( - progress_token=server.request_context.request_id, + await ctx.session.send_progress_notification( + progress_token=ctx.request_id, progress=50.0, total=100.0, message="Halfway done", ) - return [types.TextContent(type="text", text="progress_result")] - raise ValueError(f"Unknown tool: {name}") # pragma: no cover - - @server.list_tools() - async def handle_list_tools() -> list[types.Tool]: - return [ - types.Tool( - name="progress_tool", - description="A tool that sends progress notifications", - inputSchema={}, - ) - ] + return types.CallToolResult(content=[types.TextContent(type="text", text="progress_result")]) + raise ValueError(f"Unknown tool: {params.name}") # pragma: no cover + + async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="progress_tool", + description="A tool that sends progress notifications", + input_schema={}, + ) + ] + ) + + server = Server( + name="TestProgressServer", + on_call_tool=handle_call_tool, + on_list_tools=handle_list_tools, + ) # Test with mocked logging - with patch("mcp.shared.session.logging.error", side_effect=mock_log_error): - async with create_connected_server_and_client_session(server) as client_session: - # Send a request with a failing progress callback - result = await client_session.send_request( - types.ClientRequest( - types.CallToolRequest( - method="tools/call", - params=types.CallToolRequestParams(name="progress_tool", arguments={}), - ) - ), - types.CallToolResult, + with patch("mcp.shared.session.logging.exception", side_effect=mock_log_exception): + async with Client(server) as client: + # Call tool with a failing progress callback + result = await client.call_tool( + "progress_tool", + arguments={}, progress_callback=failing_progress_callback, ) diff --git a/tests/shared/test_session.py b/tests/shared/test_session.py index 313ec9926..d7c6cc3b5 100644 --- a/tests/shared/test_session.py +++ b/tests/shared/test_session.py @@ -1,107 +1,87 @@ -from collections.abc import AsyncGenerator -from typing import Any - import anyio import pytest -import mcp.types as types +from mcp import Client, types from mcp.client.session import ClientSession -from mcp.server.lowlevel.server import Server -from mcp.shared.exceptions import McpError -from mcp.shared.memory import create_client_server_memory_streams, create_connected_server_and_client_session +from mcp.server import Server, ServerRequestContext +from mcp.shared.exceptions import MCPError +from mcp.shared.memory import create_client_server_memory_streams +from mcp.shared.message import SessionMessage +from mcp.shared.session import RequestResponder from mcp.types import ( + PARSE_ERROR, CancelledNotification, CancelledNotificationParams, - ClientNotification, - ClientRequest, + ClientResult, EmptyResult, - TextContent, + ErrorData, + JSONRPCError, + JSONRPCRequest, + JSONRPCResponse, + ServerNotification, + ServerRequest, ) -@pytest.fixture -def mcp_server() -> Server: - return Server(name="test server") - - -@pytest.fixture -async def client_connected_to_server( - mcp_server: Server, -) -> AsyncGenerator[ClientSession, None]: - async with create_connected_server_and_client_session(mcp_server) as client_session: - yield client_session - - @pytest.mark.anyio -async def test_in_flight_requests_cleared_after_completion( - client_connected_to_server: ClientSession, -): +async def test_in_flight_requests_cleared_after_completion(): """Verify that _in_flight is empty after all requests complete.""" - # Send a request and wait for response - response = await client_connected_to_server.send_ping() - assert isinstance(response, EmptyResult) + server = Server(name="test server") + async with Client(server) as client: + # Send a request and wait for response + response = await client.send_ping() + assert isinstance(response, EmptyResult) - # Verify _in_flight is empty - assert len(client_connected_to_server._in_flight) == 0 + # Verify _in_flight is empty + assert len(client.session._in_flight) == 0 @pytest.mark.anyio async def test_request_cancellation(): """Test that requests can be cancelled while in-flight.""" - # The tool is already registered in the fixture - ev_tool_called = anyio.Event() ev_cancelled = anyio.Event() request_id = None - # Start the request in a separate task so we can cancel it - def make_server() -> Server: - server = Server(name="TestSessionServer") - - # Register the tool handler - @server.call_tool() - async def handle_call_tool(name: str, arguments: dict[str, Any] | None) -> list[TextContent]: - nonlocal request_id, ev_tool_called - if name == "slow_tool": - request_id = server.request_context.request_id - ev_tool_called.set() - await anyio.sleep(10) # Long enough to ensure we can cancel - return [] # pragma: no cover - raise ValueError(f"Unknown tool: {name}") # pragma: no cover - - # Register the tool so it shows up in list_tools - @server.list_tools() - async def handle_list_tools() -> list[types.Tool]: - return [ - types.Tool( - name="slow_tool", - description="A slow tool that takes 10 seconds to complete", - inputSchema={}, - ) - ] + # Create a server with a slow tool + async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + nonlocal request_id, ev_tool_called + if params.name == "slow_tool": + request_id = ctx.request_id + ev_tool_called.set() + await anyio.sleep(10) # Long enough to ensure we can cancel + return types.CallToolResult(content=[]) # pragma: no cover + raise ValueError(f"Unknown tool: {params.name}") # pragma: no cover - return server + async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + raise NotImplementedError - async def make_request(client_session: ClientSession): + server = Server( + name="TestSessionServer", + on_call_tool=handle_call_tool, + on_list_tools=handle_list_tools, + ) + + async def make_request(client: Client): nonlocal ev_cancelled try: - await client_session.send_request( - ClientRequest( - types.CallToolRequest( - params=types.CallToolRequestParams(name="slow_tool", arguments={}), - ) + await client.session.send_request( + types.CallToolRequest( + params=types.CallToolRequestParams(name="slow_tool", arguments={}), ), types.CallToolResult, ) pytest.fail("Request should have been cancelled") # pragma: no cover - except McpError as e: + except MCPError as e: # Expected - request was cancelled assert "Request cancelled" in str(e) ev_cancelled.set() - async with create_connected_server_and_client_session(make_server()) as client_session: - async with anyio.create_task_group() as tg: - tg.start_soon(make_request, client_session) + async with Client(server) as client: + async with anyio.create_task_group() as tg: # pragma: no branch + tg.start_soon(make_request, client) # Wait for the request to be in-flight with anyio.fail_after(1): # Timeout after 1 second @@ -109,24 +89,178 @@ async def make_request(client_session: ClientSession): # Send cancellation notification assert request_id is not None - await client_session.send_notification( - ClientNotification( - CancelledNotification( - params=CancelledNotificationParams(requestId=request_id), - ) - ) + await client.session.send_notification( + CancelledNotification(params=CancelledNotificationParams(request_id=request_id)) ) # Give cancellation time to process - with anyio.fail_after(1): + with anyio.fail_after(1): # pragma: no branch await ev_cancelled.wait() @pytest.mark.anyio -async def test_connection_closed(): +async def test_response_id_type_mismatch_string_to_int(): + """Test that responses with string IDs are correctly matched to requests sent with + integer IDs. + + This handles the case where a server returns "id": "0" (string) but the client + sent "id": 0 (integer). Without ID type normalization, this would cause a timeout. + """ + ev_response_received = anyio.Event() + result_holder: list[types.EmptyResult] = [] + + async with create_client_server_memory_streams() as (client_streams, server_streams): + client_read, client_write = client_streams + server_read, server_write = server_streams + + async def mock_server(): + """Receive a request and respond with a string ID instead of integer.""" + message = await server_read.receive() + assert isinstance(message, SessionMessage) + root = message.message + assert isinstance(root, JSONRPCRequest) + # Get the original request ID (which is an integer) + request_id = root.id + assert isinstance(request_id, int), f"Expected int, got {type(request_id)}" + + # Respond with the ID as a string (simulating a buggy server) + response = JSONRPCResponse( + jsonrpc="2.0", + id=str(request_id), # Convert to string to simulate mismatch + result={}, + ) + await server_write.send(SessionMessage(message=response)) + + async def make_request(client_session: ClientSession): + nonlocal result_holder + # Send a ping request (uses integer ID internally) + result = await client_session.send_ping() + result_holder.append(result) + ev_response_received.set() + + async with ( + anyio.create_task_group() as tg, + ClientSession(read_stream=client_read, write_stream=client_write) as client_session, + ): + tg.start_soon(mock_server) + tg.start_soon(make_request, client_session) + + with anyio.fail_after(2): # pragma: no branch + await ev_response_received.wait() + + assert len(result_holder) == 1 + assert isinstance(result_holder[0], EmptyResult) + + +@pytest.mark.anyio +async def test_error_response_id_type_mismatch_string_to_int(): + """Test that error responses with string IDs are correctly matched to requests + sent with integer IDs. + + This handles the case where a server returns an error with "id": "0" (string) + but the client sent "id": 0 (integer). """ - Test that pending requests are cancelled when the connection is closed remotely. + ev_error_received = anyio.Event() + error_holder: list[MCPError | Exception] = [] + + async with create_client_server_memory_streams() as (client_streams, server_streams): + client_read, client_write = client_streams + server_read, server_write = server_streams + + async def mock_server(): + """Receive a request and respond with an error using a string ID.""" + message = await server_read.receive() + assert isinstance(message, SessionMessage) + root = message.message + assert isinstance(root, JSONRPCRequest) + request_id = root.id + assert isinstance(request_id, int) + + # Respond with an error, using the ID as a string + error_response = JSONRPCError( + jsonrpc="2.0", + id=str(request_id), # Convert to string to simulate mismatch + error=ErrorData(code=-32600, message="Test error"), + ) + await server_write.send(SessionMessage(message=error_response)) + + async def make_request(client_session: ClientSession): + nonlocal error_holder + try: + await client_session.send_ping() + pytest.fail("Expected MCPError to be raised") # pragma: no cover + except MCPError as e: + error_holder.append(e) + ev_error_received.set() + + async with ( + anyio.create_task_group() as tg, + ClientSession(read_stream=client_read, write_stream=client_write) as client_session, + ): + tg.start_soon(mock_server) + tg.start_soon(make_request, client_session) + + with anyio.fail_after(2): # pragma: no branch + await ev_error_received.wait() + + assert len(error_holder) == 1 + assert "Test error" in str(error_holder[0]) + + +@pytest.mark.anyio +async def test_response_id_non_numeric_string_no_match(): + """Test that responses with non-numeric string IDs don't incorrectly match + integer request IDs. + + If a server returns "id": "abc" (non-numeric string), it should not match + a request sent with "id": 0 (integer). """ + ev_timeout = anyio.Event() + + async with create_client_server_memory_streams() as (client_streams, server_streams): + client_read, client_write = client_streams + server_read, server_write = server_streams + + async def mock_server(): + """Receive a request and respond with a non-numeric string ID.""" + message = await server_read.receive() + assert isinstance(message, SessionMessage) + + # Respond with a non-numeric string ID (should not match) + response = JSONRPCResponse( + jsonrpc="2.0", + id="not_a_number", # Non-numeric string + result={}, + ) + await server_write.send(SessionMessage(message=response)) + + async def make_request(client_session: ClientSession): + try: + # Use a short timeout since we expect this to fail + await client_session.send_request( + types.PingRequest(), + types.EmptyResult, + request_read_timeout_seconds=0.5, + ) + pytest.fail("Expected timeout") # pragma: no cover + except MCPError as e: + assert "Timed out" in str(e) + ev_timeout.set() + + async with ( + anyio.create_task_group() as tg, + ClientSession(read_stream=client_read, write_stream=client_write) as client_session, + ): + tg.start_soon(mock_server) + tg.start_soon(make_request, client_session) + + with anyio.fail_after(2): # pragma: no branch + await ev_timeout.wait() + + +@pytest.mark.anyio +async def test_connection_closed(): + """Test that pending requests are cancelled when the connection is closed remotely.""" ev_closed = anyio.Event() ev_response = anyio.Event() @@ -142,7 +276,7 @@ async def make_request(client_session: ClientSession): # any request will do await client_session.initialize() pytest.fail("Request should have errored") # pragma: no cover - except McpError as e: + except MCPError as e: # Expected - request errored assert "Connection closed" in str(e) ev_response.set() @@ -166,5 +300,119 @@ async def mock_server(): with anyio.fail_after(1): await ev_closed.wait() - with anyio.fail_after(1): + with anyio.fail_after(1): # pragma: no branch await ev_response.wait() + + +@pytest.mark.anyio +async def test_null_id_error_surfaced_via_message_handler(): + """Test that a JSONRPCError with id=None is surfaced to the message handler. + + Per JSON-RPC 2.0, error responses use id=null when the request id could not + be determined (e.g., parse errors). These cannot be correlated to any pending + request, so they are forwarded to the message handler as MCPError. + """ + ev_error_received = anyio.Event() + error_holder: list[MCPError] = [] + + async def capture_errors( + message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, + ) -> None: + assert isinstance(message, MCPError) + error_holder.append(message) + ev_error_received.set() + + sent_error = ErrorData(code=PARSE_ERROR, message="Parse error") + + async with create_client_server_memory_streams() as (client_streams, server_streams): + client_read, client_write = client_streams + _server_read, server_write = server_streams + + async def mock_server(): + """Send a null-id error (simulating a parse error).""" + error_response = JSONRPCError(jsonrpc="2.0", id=None, error=sent_error) + await server_write.send(SessionMessage(message=error_response)) + + async with ( + anyio.create_task_group() as tg, + ClientSession( + read_stream=client_read, + write_stream=client_write, + message_handler=capture_errors, + ) as _client_session, + ): + tg.start_soon(mock_server) + + with anyio.fail_after(2): # pragma: no branch + await ev_error_received.wait() + + assert len(error_holder) == 1 + assert error_holder[0].error == sent_error + + +@pytest.mark.anyio +async def test_null_id_error_does_not_affect_pending_request(): + """Test that a null-id error doesn't interfere with an in-flight request. + + When a null-id error arrives while a request is pending, the error should + go to the message handler and the pending request should still complete + normally with its own response. + """ + ev_error_received = anyio.Event() + ev_response_received = anyio.Event() + error_holder: list[MCPError] = [] + result_holder: list[EmptyResult] = [] + + async def capture_errors( + message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, + ) -> None: + assert isinstance(message, MCPError) + error_holder.append(message) + ev_error_received.set() + + sent_error = ErrorData(code=PARSE_ERROR, message="Parse error") + + async with create_client_server_memory_streams() as (client_streams, server_streams): + client_read, client_write = client_streams + server_read, server_write = server_streams + + async def mock_server(): + """Read a request, inject a null-id error, then respond normally.""" + message = await server_read.receive() + assert isinstance(message, SessionMessage) + assert isinstance(message.message, JSONRPCRequest) + request_id = message.message.id + + # First, send a null-id error (should go to message handler) + await server_write.send(SessionMessage(message=JSONRPCError(jsonrpc="2.0", id=None, error=sent_error))) + + # Then, respond normally to the pending request + await server_write.send(SessionMessage(message=JSONRPCResponse(jsonrpc="2.0", id=request_id, result={}))) + + async def make_request(client_session: ClientSession): + result = await client_session.send_ping() + result_holder.append(result) + ev_response_received.set() + + async with ( + anyio.create_task_group() as tg, + ClientSession( + read_stream=client_read, + write_stream=client_write, + message_handler=capture_errors, + ) as client_session, + ): + tg.start_soon(mock_server) + tg.start_soon(make_request, client_session) + + with anyio.fail_after(2): # pragma: no branch + await ev_error_received.wait() + await ev_response_received.wait() + + # Null-id error reached the message handler + assert len(error_holder) == 1 + assert error_holder[0].error == sent_error + + # Pending request completed successfully + assert len(result_holder) == 1 + assert isinstance(result_holder[0], EmptyResult) diff --git a/tests/shared/test_sse.py b/tests/shared/test_sse.py index 7604450f8..7b2bc0a13 100644 --- a/tests/shared/test_sse.py +++ b/tests/shared/test_sse.py @@ -1,10 +1,10 @@ import json import multiprocessing import socket -import time from collections.abc import AsyncGenerator, Generator from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch +from urllib.parse import urlparse import anyio import httpx @@ -12,26 +12,29 @@ import uvicorn from httpx_sse import ServerSentEvent from inline_snapshot import snapshot -from pydantic import AnyUrl from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import Response from starlette.routing import Mount, Route import mcp.client.sse -import mcp.types as types +from mcp import types from mcp.client.session import ClientSession from mcp.client.sse import _extract_session_id_from_endpoint, sse_client -from mcp.server import Server +from mcp.server import Server, ServerRequestContext from mcp.server.sse import SseServerTransport from mcp.server.transport_security import TransportSecuritySettings -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.types import ( + CallToolRequestParams, + CallToolResult, EmptyResult, - ErrorData, Implementation, InitializeResult, JSONRPCResponse, + ListToolsResult, + PaginatedRequestParams, + ReadResourceRequestParams, ReadResourceResult, ServerCapabilities, TextContent, @@ -55,35 +58,48 @@ def server_url(server_port: int) -> str: return f"http://127.0.0.1:{server_port}" -# Test server implementation -class ServerTest(Server): # pragma: no cover - def __init__(self): - super().__init__(SERVER_NAME) - - @self.read_resource() - async def handle_read_resource(uri: AnyUrl) -> str | bytes: - if uri.scheme == "foobar": - return f"Read {uri.host}" - elif uri.scheme == "slow": - # Simulate a slow resource - await anyio.sleep(2.0) - return f"Slow response from {uri.host}" - - raise McpError(error=ErrorData(code=404, message="OOPS! no resource with that URI was found")) - - @self.list_tools() - async def handle_list_tools() -> list[Tool]: - return [ - Tool( - name="test_tool", - description="A test tool", - inputSchema={"type": "object", "properties": {}}, - ) - ] +async def _handle_read_resource( # pragma: no cover + ctx: ServerRequestContext, params: ReadResourceRequestParams +) -> ReadResourceResult: + uri = str(params.uri) + parsed = urlparse(uri) + if parsed.scheme == "foobar": + text = f"Read {parsed.netloc}" + elif parsed.scheme == "slow": + await anyio.sleep(2.0) + text = f"Slow response from {parsed.netloc}" + else: + raise MCPError(code=404, message="OOPS! no resource with that URI was found") + return ReadResourceResult(contents=[TextResourceContents(uri=uri, text=text, mime_type="text/plain")]) + + +async def _handle_list_tools( # pragma: no cover + ctx: ServerRequestContext, params: PaginatedRequestParams | None +) -> ListToolsResult: + return ListToolsResult( + tools=[ + Tool( + name="test_tool", + description="A test tool", + input_schema={"type": "object", "properties": {}}, + ) + ] + ) - @self.call_tool() - async def handle_call_tool(name: str, args: dict[str, Any]) -> list[TextContent]: - return [TextContent(type="text", text=f"Called {name}")] + +async def _handle_call_tool( # pragma: no cover + ctx: ServerRequestContext, params: CallToolRequestParams +) -> CallToolResult: + return CallToolResult(content=[TextContent(type="text", text=f"Called {params.name}")]) + + +def _create_server() -> Server: # pragma: no cover + return Server( + SERVER_NAME, + on_read_resource=_handle_read_resource, + on_list_tools=_handle_list_tools, + on_call_tool=_handle_call_tool, + ) # Test fixtures @@ -94,7 +110,7 @@ def make_server_app() -> Starlette: # pragma: no cover allowed_hosts=["127.0.0.1:*", "localhost:*"], allowed_origins=["http://127.0.0.1:*", "http://localhost:*"] ) sse = SseServerTransport("/messages/", security_settings=security_settings) - server = ServerTest() + server = _create_server() async def handle_sse(request: Request) -> Response: async with sse.connect_sse(request.scope, request.receive, request._send) as streams: @@ -117,11 +133,6 @@ def run_server(server_port: int) -> None: # pragma: no cover print(f"starting server on {server_port}") server.run() - # Give server time to start - while not server.started: - print("waiting for server to start") - time.sleep(0.5) - @pytest.fixture() def server(server_port: int) -> Generator[None, None, None]: @@ -183,7 +194,7 @@ async def test_sse_client_basic_connection(server: None, server_url: str) -> Non # Test initialization result = await session.initialize() assert isinstance(result, InitializeResult) - assert result.serverInfo.name == SERVER_NAME + assert result.server_info.name == SERVER_NAME # Test ping ping_result = await session.send_ping() @@ -203,8 +214,8 @@ def on_session_created(session_id: str) -> None: result = await session.initialize() assert isinstance(result, InitializeResult) - assert captured_session_id is not None - assert len(captured_session_id) > 0 + assert captured_session_id is not None # pragma: lax no cover + assert len(captured_session_id) > 0 # pragma: lax no cover @pytest.mark.parametrize( @@ -238,7 +249,7 @@ def mock_extract(url: str) -> None: result = await session.initialize() assert isinstance(result, InitializeResult) - callback_mock.assert_not_called() + callback_mock.assert_not_called() # pragma: lax no cover @pytest.fixture @@ -254,7 +265,7 @@ async def test_sse_client_happy_request_and_response( initialized_sse_client_session: ClientSession, ) -> None: session = initialized_sse_client_session - response = await session.read_resource(uri=AnyUrl("foobar://should-work")) + response = await session.read_resource(uri="foobar://should-work") assert len(response.contents) == 1 assert isinstance(response.contents[0], TextResourceContents) assert response.contents[0].text == "Read should-work" @@ -265,8 +276,8 @@ async def test_sse_client_exception_handling( initialized_sse_client_session: ClientSession, ) -> None: session = initialized_sse_client_session - with pytest.raises(McpError, match="OOPS! no resource with that URI was found"): - await session.read_resource(uri=AnyUrl("xxx://will-not-work")) + with pytest.raises(MCPError, match="OOPS! no resource with that URI was found"): + await session.read_resource(uri="xxx://will-not-work") @pytest.mark.anyio @@ -277,12 +288,12 @@ async def test_sse_client_timeout( # pragma: no cover session = initialized_sse_client_session # sanity check that normal, fast responses are working - response = await session.read_resource(uri=AnyUrl("foobar://1")) + response = await session.read_resource(uri="foobar://1") assert isinstance(response, ReadResourceResult) with anyio.move_on_after(3): - with pytest.raises(McpError, match="Read timed out"): - response = await session.read_resource(uri=AnyUrl("slow://2")) + with pytest.raises(MCPError, match="Read timed out"): + response = await session.read_resource(uri="slow://2") # we should receive an error here return @@ -296,11 +307,6 @@ def run_mounted_server(server_port: int) -> None: # pragma: no cover print(f"starting server on {server_port}") server.run() - # Give server time to start - while not server.started: - print("waiting for server to start") - time.sleep(0.5) - @pytest.fixture() def mounted_server(server_port: int) -> Generator[None, None, None]: @@ -329,54 +335,53 @@ async def test_sse_client_basic_connection_mounted_app(mounted_server: None, ser # Test initialization result = await session.initialize() assert isinstance(result, InitializeResult) - assert result.serverInfo.name == SERVER_NAME + assert result.server_info.name == SERVER_NAME # Test ping ping_result = await session.send_ping() assert isinstance(ping_result, EmptyResult) -# Test server with request context that returns headers in the response -class RequestContextServer(Server[object, Request]): # pragma: no cover - def __init__(self): - super().__init__("request_context_server") - - @self.call_tool() - async def handle_call_tool(name: str, args: dict[str, Any]) -> list[TextContent]: - headers_info = {} - context = self.request_context - if context.request: - headers_info = dict(context.request.headers) - - if name == "echo_headers": - return [TextContent(type="text", text=json.dumps(headers_info))] - elif name == "echo_context": - context_data = { - "request_id": args.get("request_id"), - "headers": headers_info, - } - return [TextContent(type="text", text=json.dumps(context_data))] - - return [TextContent(type="text", text=f"Called {name}")] - - @self.list_tools() - async def handle_list_tools() -> list[Tool]: - return [ - Tool( - name="echo_headers", - description="Echoes request headers", - inputSchema={"type": "object", "properties": {}}, - ), - Tool( - name="echo_context", - description="Echoes request context", - inputSchema={ - "type": "object", - "properties": {"request_id": {"type": "string"}}, - "required": ["request_id"], - }, - ), - ] +async def _handle_context_call_tool( # pragma: no cover + ctx: ServerRequestContext, params: CallToolRequestParams +) -> CallToolResult: + headers_info: dict[str, Any] = {} + if ctx.request: + headers_info = dict(ctx.request.headers) + + if params.name == "echo_headers": + return CallToolResult(content=[TextContent(type="text", text=json.dumps(headers_info))]) + elif params.name == "echo_context": + context_data = { + "request_id": (params.arguments or {}).get("request_id"), + "headers": headers_info, + } + return CallToolResult(content=[TextContent(type="text", text=json.dumps(context_data))]) + + return CallToolResult(content=[TextContent(type="text", text=f"Called {params.name}")]) + + +async def _handle_context_list_tools( # pragma: no cover + ctx: ServerRequestContext, params: PaginatedRequestParams | None +) -> ListToolsResult: + return ListToolsResult( + tools=[ + Tool( + name="echo_headers", + description="Echoes request headers", + input_schema={"type": "object", "properties": {}}, + ), + Tool( + name="echo_context", + description="Echoes request context", + input_schema={ + "type": "object", + "properties": {"request_id": {"type": "string"}}, + "required": ["request_id"], + }, + ), + ] + ) def run_context_server(server_port: int) -> None: # pragma: no cover @@ -386,7 +391,11 @@ def run_context_server(server_port: int) -> None: # pragma: no cover allowed_hosts=["127.0.0.1:*", "localhost:*"], allowed_origins=["http://127.0.0.1:*", "http://localhost:*"] ) sse = SseServerTransport("/messages/", security_settings=security_settings) - context_server = RequestContextServer() + context_server = Server( + "request_context_server", + on_call_tool=_handle_context_call_tool, + on_list_tools=_handle_context_list_tools, + ) async def handle_sse(request: Request) -> Response: async with sse.connect_sse(request.scope, request.receive, request._send) as streams: @@ -502,12 +511,12 @@ def test_sse_message_id_coercion(): See <https://www.jsonrpc.org/specification#response_object> for more details. """ json_message = '{"jsonrpc": "2.0", "id": "123", "method": "ping", "params": null}' - msg = types.JSONRPCMessage.model_validate_json(json_message) - assert msg == snapshot(types.JSONRPCMessage(root=types.JSONRPCRequest(method="ping", jsonrpc="2.0", id="123"))) + msg = types.JSONRPCRequest.model_validate_json(json_message) + assert msg == snapshot(types.JSONRPCRequest(method="ping", jsonrpc="2.0", id="123")) json_message = '{"jsonrpc": "2.0", "id": 123, "method": "ping", "params": null}' - msg = types.JSONRPCMessage.model_validate_json(json_message) - assert msg == snapshot(types.JSONRPCMessage(root=types.JSONRPCRequest(method="ping", jsonrpc="2.0", id=123))) + msg = types.JSONRPCRequest.model_validate_json(json_message) + assert msg == snapshot(types.JSONRPCRequest(method="ping", jsonrpc="2.0", id=123)) @pytest.mark.parametrize( @@ -557,9 +566,9 @@ async def test_sse_client_handles_empty_keepalive_pings() -> None: """ # Build a proper JSON-RPC response using types (not hardcoded strings) init_result = InitializeResult( - protocolVersion="2024-11-05", + protocol_version="2024-11-05", capabilities=ServerCapabilities(), - serverInfo=Implementation(name="test", version="1.0"), + server_info=Implementation(name="test", version="1.0"), ) response = JSONRPCResponse( jsonrpc="2.0", @@ -600,5 +609,5 @@ async def mock_aiter_sse() -> AsyncGenerator[ServerSentEvent, None]: msg = await read_stream.receive() # If we get here without error, the empty message was skipped successfully assert not isinstance(msg, Exception) - assert isinstance(msg.message.root, types.JSONRPCResponse) - assert msg.message.root.id == 1 + assert isinstance(msg.message, types.JSONRPCResponse) + assert msg.message.id == 1 diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index a626e7385..42b1a3698 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -1,16 +1,21 @@ -""" -Tests for the StreamableHTTP server and client transport. +"""Tests for the StreamableHTTP server and client transport. Contains tests for both server and client sides of the StreamableHTTP transport. """ +from __future__ import annotations as _annotations + import json import multiprocessing import socket import time -from collections.abc import Generator +import traceback +from collections.abc import AsyncIterator, Generator +from contextlib import asynccontextmanager +from dataclasses import dataclass, field from typing import Any from unittest.mock import MagicMock +from urllib.parse import urlparse import anyio import httpx @@ -18,15 +23,14 @@ import requests import uvicorn from httpx_sse import ServerSentEvent -from pydantic import AnyUrl from starlette.applications import Starlette from starlette.requests import Request from starlette.routing import Mount -import mcp.types as types +from mcp import MCPError, types from mcp.client.session import ClientSession -from mcp.client.streamable_http import StreamableHTTPTransport, streamablehttp_client -from mcp.server import Server +from mcp.client.streamable_http import StreamableHTTPTransport, streamable_http_client +from mcp.server import Server, ServerRequestContext from mcp.server.streamable_http import ( MCP_PROTOCOL_VERSION_HEADER, MCP_SESSION_ID_HEADER, @@ -40,14 +44,23 @@ ) from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from mcp.server.transport_security import TransportSecuritySettings -from mcp.shared.context import RequestContext -from mcp.shared.exceptions import McpError +from mcp.shared._context import RequestContext +from mcp.shared._httpx_utils import ( + MCP_DEFAULT_SSE_READ_TIMEOUT, + MCP_DEFAULT_TIMEOUT, + create_mcp_http_client, +) from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata, SessionMessage from mcp.shared.session import RequestResponder from mcp.types import ( + CallToolRequestParams, + CallToolResult, InitializeResult, - JSONRPCMessage, JSONRPCRequest, + ListToolsResult, + PaginatedRequestParams, + ReadResourceRequestParams, + ReadResourceResult, TextContent, TextResourceContents, Tool, @@ -70,14 +83,14 @@ # Helper functions -def extract_protocol_version_from_sse(response: requests.Response) -> str: # pragma: no cover +def extract_protocol_version_from_sse(response: requests.Response) -> str: """Extract the negotiated protocol version from an SSE initialization response.""" assert response.headers.get("Content-Type") == "text/event-stream" for line in response.text.splitlines(): if line.startswith("data: "): init_data = json.loads(line[6:]) return init_data["result"]["protocolVersion"] - raise ValueError("Could not extract protocol version from SSE response") + raise ValueError("Could not extract protocol version from SSE response") # pragma: no cover # Simple in-memory event store for testing @@ -88,9 +101,7 @@ def __init__(self): self._events: list[tuple[StreamId, EventId, types.JSONRPCMessage | None]] = [] self._event_id_counter = 0 - async def store_event( # pragma: no cover - self, stream_id: StreamId, message: types.JSONRPCMessage | None - ) -> EventId: + async def store_event(self, stream_id: StreamId, message: types.JSONRPCMessage | None) -> EventId: """Store an event and return its ID.""" self._event_id_counter += 1 event_id = str(self._event_id_counter) @@ -127,262 +138,258 @@ async def replay_events_after( # pragma: no cover return target_stream_id -# Test server implementation that follows MCP protocol -class ServerTest(Server): # pragma: no cover - def __init__(self): - super().__init__(SERVER_NAME) - self._lock = None # Will be initialized in async context - - @self.read_resource() - async def handle_read_resource(uri: AnyUrl) -> str | bytes: - if uri.scheme == "foobar": - return f"Read {uri.host}" - elif uri.scheme == "slow": - # Simulate a slow resource - await anyio.sleep(2.0) - return f"Slow response from {uri.host}" - - raise ValueError(f"Unknown resource: {uri}") - - @self.list_tools() - async def handle_list_tools() -> list[Tool]: - return [ - Tool( - name="test_tool", - description="A test tool", - inputSchema={"type": "object", "properties": {}}, - ), - Tool( - name="test_tool_with_standalone_notification", - description="A test tool that sends a notification", - inputSchema={"type": "object", "properties": {}}, - ), - Tool( - name="long_running_with_checkpoints", - description="A long-running tool that sends periodic notifications", - inputSchema={"type": "object", "properties": {}}, - ), - Tool( - name="test_sampling_tool", - description="A tool that triggers server-side sampling", - inputSchema={"type": "object", "properties": {}}, - ), - Tool( - name="wait_for_lock_with_notification", - description="A tool that sends a notification and waits for lock", - inputSchema={"type": "object", "properties": {}}, - ), - Tool( - name="release_lock", - description="A tool that releases the lock", - inputSchema={"type": "object", "properties": {}}, - ), - Tool( - name="tool_with_stream_close", - description="A tool that closes SSE stream mid-operation", - inputSchema={"type": "object", "properties": {}}, - ), - Tool( - name="tool_with_multiple_notifications_and_close", - description="Tool that sends notification1, closes stream, sends notification2, notification3", - inputSchema={"type": "object", "properties": {}}, - ), - Tool( - name="tool_with_multiple_stream_closes", - description="Tool that closes SSE stream multiple times during execution", - inputSchema={ - "type": "object", - "properties": { - "checkpoints": {"type": "integer", "default": 3}, - "sleep_time": {"type": "number", "default": 0.2}, - }, +@dataclass +class ServerState: + lock: anyio.Event = field(default_factory=anyio.Event) + + +@asynccontextmanager +async def _server_lifespan(_server: Server[ServerState]) -> AsyncIterator[ServerState]: # pragma: no cover + yield ServerState() + + +async def _handle_read_resource( # pragma: no cover + ctx: ServerRequestContext[ServerState], params: ReadResourceRequestParams +) -> ReadResourceResult: + uri = str(params.uri) + parsed = urlparse(uri) + if parsed.scheme == "foobar": + text = f"Read {parsed.netloc}" + elif parsed.scheme == "slow": + await anyio.sleep(2.0) + text = f"Slow response from {parsed.netloc}" + else: + raise ValueError(f"Unknown resource: {uri}") + return ReadResourceResult(contents=[TextResourceContents(uri=uri, text=text, mime_type="text/plain")]) + + +async def _handle_list_tools( # pragma: no cover + ctx: ServerRequestContext[ServerState], params: PaginatedRequestParams | None +) -> ListToolsResult: + return ListToolsResult( + tools=[ + Tool( + name="test_tool", + description="A test tool", + input_schema={"type": "object", "properties": {}}, + ), + Tool( + name="test_tool_with_standalone_notification", + description="A test tool that sends a notification", + input_schema={"type": "object", "properties": {}}, + ), + Tool( + name="long_running_with_checkpoints", + description="A long-running tool that sends periodic notifications", + input_schema={"type": "object", "properties": {}}, + ), + Tool( + name="test_sampling_tool", + description="A tool that triggers server-side sampling", + input_schema={"type": "object", "properties": {}}, + ), + Tool( + name="wait_for_lock_with_notification", + description="A tool that sends a notification and waits for lock", + input_schema={"type": "object", "properties": {}}, + ), + Tool( + name="release_lock", + description="A tool that releases the lock", + input_schema={"type": "object", "properties": {}}, + ), + Tool( + name="tool_with_stream_close", + description="A tool that closes SSE stream mid-operation", + input_schema={"type": "object", "properties": {}}, + ), + Tool( + name="tool_with_multiple_notifications_and_close", + description="Tool that sends notification1, closes stream, sends notification2, notification3", + input_schema={"type": "object", "properties": {}}, + ), + Tool( + name="tool_with_multiple_stream_closes", + description="Tool that closes SSE stream multiple times during execution", + input_schema={ + "type": "object", + "properties": { + "checkpoints": {"type": "integer", "default": 3}, + "sleep_time": {"type": "number", "default": 0.2}, }, - ), - Tool( - name="tool_with_standalone_stream_close", - description="Tool that closes standalone GET stream mid-operation", - inputSchema={"type": "object", "properties": {}}, - ), - ] - - @self.call_tool() - async def handle_call_tool(name: str, args: dict[str, Any]) -> list[TextContent]: - ctx = self.request_context + }, + ), + Tool( + name="tool_with_standalone_stream_close", + description="Tool that closes standalone GET stream mid-operation", + input_schema={"type": "object", "properties": {}}, + ), + ] + ) - # When the tool is called, send a notification to test GET stream - if name == "test_tool_with_standalone_notification": - await ctx.session.send_resource_updated(uri=AnyUrl("http://test_resource")) - return [TextContent(type="text", text=f"Called {name}")] - elif name == "long_running_with_checkpoints": - # Send notifications that are part of the response stream - # This simulates a long-running tool that sends logs +async def _handle_call_tool( # pragma: no cover + ctx: ServerRequestContext[ServerState], params: CallToolRequestParams +) -> CallToolResult: + name = params.name + args = params.arguments or {} - await ctx.session.send_log_message( - level="info", - data="Tool started", - logger="tool", - related_request_id=ctx.request_id, # need for stream association - ) + # When the tool is called, send a notification to test GET stream + if name == "test_tool_with_standalone_notification": + await ctx.session.send_resource_updated(uri="http://test_resource") + return CallToolResult(content=[TextContent(type="text", text=f"Called {name}")]) - await anyio.sleep(0.1) + elif name == "long_running_with_checkpoints": + await ctx.session.send_log_message( + level="info", + data="Tool started", + logger="tool", + related_request_id=ctx.request_id, + ) - await ctx.session.send_log_message( - level="info", - data="Tool is almost done", - logger="tool", - related_request_id=ctx.request_id, - ) + await anyio.sleep(0.1) - return [TextContent(type="text", text="Completed!")] + await ctx.session.send_log_message( + level="info", + data="Tool is almost done", + logger="tool", + related_request_id=ctx.request_id, + ) - elif name == "test_sampling_tool": - # Test sampling by requesting the client to sample a message - sampling_result = await ctx.session.create_message( - messages=[ - types.SamplingMessage( - role="user", - content=types.TextContent(type="text", text="Server needs client sampling"), - ) - ], - max_tokens=100, - related_request_id=ctx.request_id, - ) + return CallToolResult(content=[TextContent(type="text", text="Completed!")]) - # Return the sampling result in the tool response - # Since we're not passing tools param, result.content is single content - if sampling_result.content.type == "text": - response = sampling_result.content.text - else: - response = str(sampling_result.content) - return [ - TextContent( - type="text", - text=f"Response from sampling: {response}", - ) - ] - - elif name == "wait_for_lock_with_notification": - # Initialize lock if not already done - if self._lock is None: - self._lock = anyio.Event() - - # First send a notification - await ctx.session.send_log_message( - level="info", - data="First notification before lock", - logger="lock_tool", - related_request_id=ctx.request_id, + elif name == "test_sampling_tool": + sampling_result = await ctx.session.create_message( + messages=[ + types.SamplingMessage( + role="user", + content=types.TextContent(type="text", text="Server needs client sampling"), ) + ], + max_tokens=100, + related_request_id=ctx.request_id, + ) - # Now wait for the lock to be released - await self._lock.wait() - - # Send second notification after lock is released - await ctx.session.send_log_message( - level="info", - data="Second notification after lock", - logger="lock_tool", - related_request_id=ctx.request_id, + if sampling_result.content.type == "text": + response = sampling_result.content.text + else: + response = str(sampling_result.content) + return CallToolResult( + content=[ + TextContent( + type="text", + text=f"Response from sampling: {response}", ) + ] + ) - return [TextContent(type="text", text="Completed")] + elif name == "wait_for_lock_with_notification": + await ctx.session.send_log_message( + level="info", + data="First notification before lock", + logger="lock_tool", + related_request_id=ctx.request_id, + ) - elif name == "release_lock": - assert self._lock is not None, "Lock must be initialized before releasing" + await ctx.lifespan_context.lock.wait() - # Release the lock - self._lock.set() - return [TextContent(type="text", text="Lock released")] + await ctx.session.send_log_message( + level="info", + data="Second notification after lock", + logger="lock_tool", + related_request_id=ctx.request_id, + ) - elif name == "tool_with_stream_close": - # Send notification before closing - await ctx.session.send_log_message( - level="info", - data="Before close", - logger="stream_close_tool", - related_request_id=ctx.request_id, - ) - # Close SSE stream (triggers client reconnect) - assert ctx.close_sse_stream is not None - await ctx.close_sse_stream() - # Continue processing (events stored in event_store) - await anyio.sleep(0.1) - await ctx.session.send_log_message( - level="info", - data="After close", - logger="stream_close_tool", - related_request_id=ctx.request_id, - ) - return [TextContent(type="text", text="Done")] - - elif name == "tool_with_multiple_notifications_and_close": - # Send notification1 - await ctx.session.send_log_message( - level="info", - data="notification1", - logger="multi_notif_tool", - related_request_id=ctx.request_id, - ) - # Close SSE stream - assert ctx.close_sse_stream is not None - await ctx.close_sse_stream() - # Send notification2, notification3 (stored in event_store) - await anyio.sleep(0.1) - await ctx.session.send_log_message( - level="info", - data="notification2", - logger="multi_notif_tool", - related_request_id=ctx.request_id, - ) - await ctx.session.send_log_message( - level="info", - data="notification3", - logger="multi_notif_tool", - related_request_id=ctx.request_id, - ) - return [TextContent(type="text", text="All notifications sent")] + return CallToolResult(content=[TextContent(type="text", text="Completed")]) - elif name == "tool_with_multiple_stream_closes": - num_checkpoints = args.get("checkpoints", 3) - sleep_time = args.get("sleep_time", 0.2) + elif name == "release_lock": + ctx.lifespan_context.lock.set() + return CallToolResult(content=[TextContent(type="text", text="Lock released")]) - for i in range(num_checkpoints): - await ctx.session.send_log_message( - level="info", - data=f"checkpoint_{i}", - logger="multi_close_tool", - related_request_id=ctx.request_id, - ) + elif name == "tool_with_stream_close": + await ctx.session.send_log_message( + level="info", + data="Before close", + logger="stream_close_tool", + related_request_id=ctx.request_id, + ) + assert ctx.close_sse_stream is not None + await ctx.close_sse_stream() + await anyio.sleep(0.1) + await ctx.session.send_log_message( + level="info", + data="After close", + logger="stream_close_tool", + related_request_id=ctx.request_id, + ) + return CallToolResult(content=[TextContent(type="text", text="Done")]) + + elif name == "tool_with_multiple_notifications_and_close": + await ctx.session.send_log_message( + level="info", + data="notification1", + logger="multi_notif_tool", + related_request_id=ctx.request_id, + ) + assert ctx.close_sse_stream is not None + await ctx.close_sse_stream() + await anyio.sleep(0.1) + await ctx.session.send_log_message( + level="info", + data="notification2", + logger="multi_notif_tool", + related_request_id=ctx.request_id, + ) + await ctx.session.send_log_message( + level="info", + data="notification3", + logger="multi_notif_tool", + related_request_id=ctx.request_id, + ) + return CallToolResult(content=[TextContent(type="text", text="All notifications sent")]) + + elif name == "tool_with_multiple_stream_closes": + num_checkpoints = args.get("checkpoints", 3) + sleep_time = args.get("sleep_time", 0.2) + + for i in range(num_checkpoints): + await ctx.session.send_log_message( + level="info", + data=f"checkpoint_{i}", + logger="multi_close_tool", + related_request_id=ctx.request_id, + ) - if ctx.close_sse_stream: - await ctx.close_sse_stream() + if ctx.close_sse_stream: + await ctx.close_sse_stream() - await anyio.sleep(sleep_time) + await anyio.sleep(sleep_time) - return [TextContent(type="text", text=f"Completed {num_checkpoints} checkpoints")] + return CallToolResult(content=[TextContent(type="text", text=f"Completed {num_checkpoints} checkpoints")]) - elif name == "tool_with_standalone_stream_close": - # Test for GET stream reconnection - # 1. Send unsolicited notification via GET stream (no related_request_id) - await ctx.session.send_resource_updated(uri=AnyUrl("http://notification_1")) + elif name == "tool_with_standalone_stream_close": + await ctx.session.send_resource_updated(uri="http://notification_1") + await anyio.sleep(0.1) - # Small delay to ensure notification is flushed before closing - await anyio.sleep(0.1) + if ctx.close_standalone_sse_stream: + await ctx.close_standalone_sse_stream() - # 2. Close the standalone GET stream - if ctx.close_standalone_sse_stream: - await ctx.close_standalone_sse_stream() + await anyio.sleep(1.5) + await ctx.session.send_resource_updated(uri="http://notification_2") - # 3. Wait for client to reconnect (uses retry_interval from server, default 1000ms) - await anyio.sleep(1.5) + return CallToolResult(content=[TextContent(type="text", text="Standalone stream close test done")]) - # 4. Send another notification on the new GET stream connection - await ctx.session.send_resource_updated(uri=AnyUrl("http://notification_2")) + return CallToolResult(content=[TextContent(type="text", text=f"Called {name}")]) - return [TextContent(type="text", text="Standalone stream close test done")] - return [TextContent(type="text", text=f"Called {name}")] +def _create_server() -> Server[ServerState]: # pragma: no cover + return Server( + SERVER_NAME, + lifespan=_server_lifespan, + on_read_resource=_handle_read_resource, + on_list_tools=_handle_list_tools, + on_call_tool=_handle_call_tool, + ) def create_app( @@ -398,7 +405,7 @@ def create_app( retry_interval: Retry interval in milliseconds for SSE polling. """ # Create server instance - server = ServerTest() + server = _create_server() # Create the session manager security_settings = TransportSecuritySettings( @@ -458,8 +465,6 @@ def run_server( try: server.run() except Exception: - import traceback - traceback.print_exc() @@ -870,7 +875,7 @@ def test_get_sse_stream(basic_server: None, basic_server_url: str): init_data = None assert init_response.headers.get("Content-Type") == "text/event-stream" for line in init_response.text.splitlines(): # pragma: no branch - if line.startswith("data: "): # pragma: no cover + if line.startswith("data: "): init_data = json.loads(line[6:]) break assert init_data is not None @@ -930,7 +935,7 @@ def test_get_validation(basic_server: None, basic_server_url: str): init_data = None assert init_response.headers.get("Content-Type") == "text/event-stream" for line in init_response.text.splitlines(): # pragma: no branch - if line.startswith("data: "): # pragma: no cover + if line.startswith("data: "): init_data = json.loads(line[6:]) break assert init_data is not None @@ -972,49 +977,35 @@ async def http_client(basic_server: None, basic_server_url: str): # pragma: no @pytest.fixture async def initialized_client_session(basic_server: None, basic_server_url: str): """Create initialized StreamableHTTP client session.""" - async with streamablehttp_client(f"{basic_server_url}/mcp") as ( - read_stream, - write_stream, - _, - ): - async with ClientSession( - read_stream, - write_stream, - ) as session: + async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: await session.initialize() yield session @pytest.mark.anyio -async def test_streamablehttp_client_basic_connection(basic_server: None, basic_server_url: str): +async def test_streamable_http_client_basic_connection(basic_server: None, basic_server_url: str): """Test basic client connection with initialization.""" - async with streamablehttp_client(f"{basic_server_url}/mcp") as ( - read_stream, - write_stream, - _, - ): - async with ClientSession( - read_stream, - write_stream, - ) as session: + async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: # Test initialization result = await session.initialize() assert isinstance(result, InitializeResult) - assert result.serverInfo.name == SERVER_NAME + assert result.server_info.name == SERVER_NAME @pytest.mark.anyio -async def test_streamablehttp_client_resource_read(initialized_client_session: ClientSession): +async def test_streamable_http_client_resource_read(initialized_client_session: ClientSession): """Test client resource read functionality.""" - response = await initialized_client_session.read_resource(uri=AnyUrl("foobar://test-resource")) + response = await initialized_client_session.read_resource(uri="foobar://test-resource") assert len(response.contents) == 1 - assert response.contents[0].uri == AnyUrl("foobar://test-resource") + assert response.contents[0].uri == "foobar://test-resource" assert isinstance(response.contents[0], TextResourceContents) assert response.contents[0].text == "Read test-resource" @pytest.mark.anyio -async def test_streamablehttp_client_tool_invocation(initialized_client_session: ClientSession): +async def test_streamable_http_client_tool_invocation(initialized_client_session: ClientSession): """Test client tool invocation.""" # First list tools tools = await initialized_client_session.list_tools() @@ -1029,26 +1020,19 @@ async def test_streamablehttp_client_tool_invocation(initialized_client_session: @pytest.mark.anyio -async def test_streamablehttp_client_error_handling(initialized_client_session: ClientSession): +async def test_streamable_http_client_error_handling(initialized_client_session: ClientSession): """Test error handling in client.""" - with pytest.raises(McpError) as exc_info: - await initialized_client_session.read_resource(uri=AnyUrl("unknown://test-error")) + with pytest.raises(MCPError) as exc_info: + await initialized_client_session.read_resource(uri="unknown://test-error") assert exc_info.value.error.code == 0 assert "Unknown resource: unknown://test-error" in exc_info.value.error.message @pytest.mark.anyio -async def test_streamablehttp_client_session_persistence(basic_server: None, basic_server_url: str): +async def test_streamable_http_client_session_persistence(basic_server: None, basic_server_url: str): """Test that session ID persists across requests.""" - async with streamablehttp_client(f"{basic_server_url}/mcp") as ( - read_stream, - write_stream, - _, - ): - async with ClientSession( - read_stream, - write_stream, - ) as session: + async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: # Initialize the session result = await session.initialize() assert isinstance(result, InitializeResult) @@ -1058,7 +1042,7 @@ async def test_streamablehttp_client_session_persistence(basic_server: None, bas assert len(tools.tools) == 10 # Read a resource - resource = await session.read_resource(uri=AnyUrl("foobar://test-persist")) + resource = await session.read_resource(uri="foobar://test-persist") assert isinstance(resource.contents[0], TextResourceContents) is True content = resource.contents[0] assert isinstance(content, TextResourceContents) @@ -1066,21 +1050,14 @@ async def test_streamablehttp_client_session_persistence(basic_server: None, bas @pytest.mark.anyio -async def test_streamablehttp_client_json_response(json_response_server: None, json_server_url: str): +async def test_streamable_http_client_json_response(json_response_server: None, json_server_url: str): """Test client with JSON response mode.""" - async with streamablehttp_client(f"{json_server_url}/mcp") as ( - read_stream, - write_stream, - _, - ): - async with ClientSession( - read_stream, - write_stream, - ) as session: + async with streamable_http_client(f"{json_server_url}/mcp") as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: # Initialize the session result = await session.initialize() assert isinstance(result, InitializeResult) - assert result.serverInfo.name == SERVER_NAME + assert result.server_info.name == SERVER_NAME # Check tool listing tools = await session.list_tools() @@ -1094,10 +1071,8 @@ async def test_streamablehttp_client_json_response(json_response_server: None, j @pytest.mark.anyio -async def test_streamablehttp_client_get_stream(basic_server: None, basic_server_url: str): +async def test_streamable_http_client_get_stream(basic_server: None, basic_server_url: str): """Test GET stream functionality for server-initiated messages.""" - import mcp.types as types - notifications_received: list[types.ServerNotification] = [] # Define message handler to capture notifications @@ -1107,11 +1082,7 @@ async def message_handler( # pragma: no branch if isinstance(message, types.ServerNotification): # pragma: no branch notifications_received.append(message) - async with streamablehttp_client(f"{basic_server_url}/mcp") as ( - read_stream, - write_stream, - _, - ): + async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream): async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session: # Initialize the session - this triggers the GET stream setup result = await session.initialize() @@ -1126,56 +1097,70 @@ async def message_handler( # pragma: no branch # Verify the notification is a ResourceUpdatedNotification resource_update_found = False for notif in notifications_received: - if isinstance(notif.root, types.ResourceUpdatedNotification): # pragma: no branch - assert str(notif.root.params.uri) == "http://test_resource/" + if isinstance(notif, types.ResourceUpdatedNotification): # pragma: no branch + assert str(notif.params.uri) == "http://test_resource" resource_update_found = True assert resource_update_found, "ResourceUpdatedNotification not received via GET stream" -@pytest.mark.anyio -async def test_streamablehttp_client_session_termination(basic_server: None, basic_server_url: str): - """Test client session termination functionality.""" +def create_session_id_capturing_client() -> tuple[httpx.AsyncClient, list[str]]: + """Create an httpx client that captures the session ID from responses.""" + captured_ids: list[str] = [] - captured_session_id = None + async def capture_session_id(response: httpx.Response) -> None: + session_id = response.headers.get(MCP_SESSION_ID_HEADER) + if session_id: + captured_ids.append(session_id) - # Create the streamablehttp_client with a custom httpx client to capture headers - async with streamablehttp_client(f"{basic_server_url}/mcp") as ( - read_stream, - write_stream, - get_session_id, - ): - async with ClientSession(read_stream, write_stream) as session: - # Initialize the session - result = await session.initialize() - assert isinstance(result, InitializeResult) - captured_session_id = get_session_id() - assert captured_session_id is not None + client = httpx.AsyncClient( + follow_redirects=True, + timeout=httpx.Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT), + event_hooks={"response": [capture_session_id]}, + ) + return client, captured_ids - # Make a request to confirm session is working - tools = await session.list_tools() - assert len(tools.tools) == 10 - headers: dict[str, str] = {} # pragma: no cover - if captured_session_id: # pragma: no cover +@pytest.mark.anyio +async def test_streamable_http_client_session_termination(basic_server: None, basic_server_url: str): + """Test client session termination functionality.""" + # Use httpx client with event hooks to capture session ID + httpx_client, captured_ids = create_session_id_capturing_client() + + async with httpx_client: + async with streamable_http_client(f"{basic_server_url}/mcp", http_client=httpx_client) as ( + read_stream, + write_stream, + ): + async with ClientSession(read_stream, write_stream) as session: + # Initialize the session + result = await session.initialize() + assert isinstance(result, InitializeResult) + assert len(captured_ids) > 0 + captured_session_id = captured_ids[0] + assert captured_session_id is not None + + # Make a request to confirm session is working + tools = await session.list_tools() + assert len(tools.tools) == 10 + + headers: dict[str, str] = {} # pragma: lax no cover + if captured_session_id: # pragma: lax no cover headers[MCP_SESSION_ID_HEADER] = captured_session_id - async with streamablehttp_client(f"{basic_server_url}/mcp", headers=headers) as ( - read_stream, - write_stream, - _, - ): - async with ClientSession(read_stream, write_stream) as session: - # Attempt to make a request after termination - with pytest.raises( # pragma: no branch - McpError, - match="Session terminated", - ): - await session.list_tools() + async with create_mcp_http_client(headers=headers) as httpx_client2: + async with streamable_http_client(f"{basic_server_url}/mcp", http_client=httpx_client2) as ( + read_stream, + write_stream, + ): + async with ClientSession(read_stream, write_stream) as session: # pragma: no branch + # Attempt to make a request after termination + with pytest.raises(MCPError, match="Session terminated"): # pragma: no branch + await session.list_tools() @pytest.mark.anyio -async def test_streamablehttp_client_session_termination_204( +async def test_streamable_http_client_session_termination_204( basic_server: None, basic_server_url: str, monkeypatch: pytest.MonkeyPatch ): """Test client session termination functionality with a 204 response. @@ -1203,53 +1188,50 @@ async def mock_delete(self: httpx.AsyncClient, *args: Any, **kwargs: Any) -> htt # Apply the patch to the httpx client monkeypatch.setattr(httpx.AsyncClient, "delete", mock_delete) - captured_session_id = None - - # Create the streamablehttp_client with a custom httpx client to capture headers - async with streamablehttp_client(f"{basic_server_url}/mcp") as ( - read_stream, - write_stream, - get_session_id, - ): - async with ClientSession(read_stream, write_stream) as session: - # Initialize the session - result = await session.initialize() - assert isinstance(result, InitializeResult) - captured_session_id = get_session_id() - assert captured_session_id is not None + # Use httpx client with event hooks to capture session ID + httpx_client, captured_ids = create_session_id_capturing_client() - # Make a request to confirm session is working - tools = await session.list_tools() - assert len(tools.tools) == 10 - - headers: dict[str, str] = {} # pragma: no cover - if captured_session_id: # pragma: no cover + async with httpx_client: + async with streamable_http_client(f"{basic_server_url}/mcp", http_client=httpx_client) as ( + read_stream, + write_stream, + ): + async with ClientSession(read_stream, write_stream) as session: + # Initialize the session + result = await session.initialize() + assert isinstance(result, InitializeResult) + assert len(captured_ids) > 0 + captured_session_id = captured_ids[0] + assert captured_session_id is not None + + # Make a request to confirm session is working + tools = await session.list_tools() + assert len(tools.tools) == 10 + + headers: dict[str, str] = {} # pragma: lax no cover + if captured_session_id: # pragma: lax no cover headers[MCP_SESSION_ID_HEADER] = captured_session_id - async with streamablehttp_client(f"{basic_server_url}/mcp", headers=headers) as ( - read_stream, - write_stream, - _, - ): - async with ClientSession(read_stream, write_stream) as session: - # Attempt to make a request after termination - with pytest.raises( # pragma: no branch - McpError, - match="Session terminated", - ): - await session.list_tools() + async with create_mcp_http_client(headers=headers) as httpx_client2: + async with streamable_http_client(f"{basic_server_url}/mcp", http_client=httpx_client2) as ( + read_stream, + write_stream, + ): + async with ClientSession(read_stream, write_stream) as session: # pragma: no branch + # Attempt to make a request after termination + with pytest.raises(MCPError, match="Session terminated"): # pragma: no branch + await session.list_tools() @pytest.mark.anyio -async def test_streamablehttp_client_resumption(event_server: tuple[SimpleEventStore, str]): +async def test_streamable_http_client_resumption(event_server: tuple[SimpleEventStore, str]): """Test client session resumption using sync primitives for reliable coordination.""" _, server_url = event_server # Variables to track the state - captured_session_id = None - captured_resumption_token = None + captured_resumption_token: str | None = None captured_notifications: list[types.ServerNotification] = [] - captured_protocol_version = None + captured_protocol_version: str | int | None = None first_notification_received = False async def message_handler( # pragma: no branch @@ -1258,8 +1240,8 @@ async def message_handler( # pragma: no branch if isinstance(message, types.ServerNotification): # pragma: no branch captured_notifications.append(message) # Look for our first notification - if isinstance(message.root, types.LoggingMessageNotification): # pragma: no branch - if message.root.params.data == "First notification before lock": + if isinstance(message, types.LoggingMessageNotification): # pragma: no branch + if message.params.data == "First notification before lock": nonlocal first_notification_received first_notification_received = True @@ -1267,99 +1249,98 @@ async def on_resumption_token_update(token: str) -> None: nonlocal captured_resumption_token captured_resumption_token = token + # Use httpx client with event hooks to capture session ID + httpx_client, captured_ids = create_session_id_capturing_client() + # First, start the client session and begin the tool that waits on lock - async with streamablehttp_client(f"{server_url}/mcp", terminate_on_close=False) as ( - read_stream, - write_stream, - get_session_id, - ): - async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session: - # Initialize the session - result = await session.initialize() - assert isinstance(result, InitializeResult) - captured_session_id = get_session_id() - assert captured_session_id is not None - # Capture the negotiated protocol version - captured_protocol_version = result.protocolVersion - - # Start the tool that will wait on lock in a task - async with anyio.create_task_group() as tg: - - async def run_tool(): - metadata = ClientMessageMetadata( - on_resumption_token_update=on_resumption_token_update, - ) - await session.send_request( - types.ClientRequest( + async with httpx_client: + async with streamable_http_client(f"{server_url}/mcp", terminate_on_close=False, http_client=httpx_client) as ( + read_stream, + write_stream, + ): + async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session: + # Initialize the session + result = await session.initialize() + assert isinstance(result, InitializeResult) + assert len(captured_ids) > 0 + captured_session_id = captured_ids[0] + assert captured_session_id is not None + # Capture the negotiated protocol version + captured_protocol_version = result.protocol_version + + # Start the tool that will wait on lock in a task + async with anyio.create_task_group() as tg: # pragma: no branch + + async def run_tool(): + metadata = ClientMessageMetadata( + on_resumption_token_update=on_resumption_token_update, + ) + await session.send_request( types.CallToolRequest( params=types.CallToolRequestParams( name="wait_for_lock_with_notification", arguments={} ), - ) - ), - types.CallToolResult, - metadata=metadata, - ) - - tg.start_soon(run_tool) - - # Wait for the first notification and resumption token - while not first_notification_received or not captured_resumption_token: - await anyio.sleep(0.1) - - # Kill the client session while tool is waiting on lock - tg.cancel_scope.cancel() - - # Verify we received exactly one notification - assert len(captured_notifications) == 1 # pragma: no cover - assert isinstance(captured_notifications[0].root, types.LoggingMessageNotification) # pragma: no cover - assert captured_notifications[0].root.params.data == "First notification before lock" # pragma: no cover + ), + types.CallToolResult, + metadata=metadata, + ) - # Clear notifications for the second phase - captured_notifications = [] # pragma: no cover + tg.start_soon(run_tool) + + # Wait for the first notification and resumption token + while not first_notification_received or not captured_resumption_token: + await anyio.sleep(0.1) + + # Kill the client session while tool is waiting on lock + tg.cancel_scope.cancel() + + # Verify we received exactly one notification (inside ClientSession + # so coverage tracks these on Python 3.11, see PR #1897 for details) + assert len(captured_notifications) == 1 # pragma: lax no cover + assert isinstance(captured_notifications[0], types.LoggingMessageNotification) # pragma: lax no cover + assert captured_notifications[0].params.data == "First notification before lock" # pragma: lax no cover + + # Clear notifications and set up headers for phase 2 (between connections, + # not tracked by coverage on Python 3.11 due to cancel scope + sys.settrace bug) + captured_notifications = [] # pragma: lax no cover + assert captured_session_id is not None # pragma: lax no cover + assert captured_protocol_version is not None # pragma: lax no cover + headers: dict[str, Any] = { # pragma: lax no cover + MCP_SESSION_ID_HEADER: captured_session_id, + MCP_PROTOCOL_VERSION_HEADER: captured_protocol_version, + } - # Now resume the session with the same mcp-session-id and protocol version - headers: dict[str, Any] = {} # pragma: no cover - if captured_session_id: # pragma: no cover - headers[MCP_SESSION_ID_HEADER] = captured_session_id - if captured_protocol_version: # pragma: no cover - headers[MCP_PROTOCOL_VERSION_HEADER] = captured_protocol_version - async with streamablehttp_client(f"{server_url}/mcp", headers=headers) as ( - read_stream, - write_stream, - _, - ): - async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session: - result = await session.send_request( - types.ClientRequest( - types.CallToolRequest( - params=types.CallToolRequestParams(name="release_lock", arguments={}), - ) - ), - types.CallToolResult, - ) - metadata = ClientMessageMetadata( - resumption_token=captured_resumption_token, - ) + async with create_mcp_http_client(headers=headers) as httpx_client2: + async with streamable_http_client(f"{server_url}/mcp", http_client=httpx_client2) as ( + read_stream, + write_stream, + ): + async with ClientSession( + read_stream, write_stream, message_handler=message_handler + ) as session: # pragma: no branch + result = await session.send_request( + types.CallToolRequest(params=types.CallToolRequestParams(name="release_lock", arguments={})), + types.CallToolResult, + ) + metadata = ClientMessageMetadata( + resumption_token=captured_resumption_token, + ) - result = await session.send_request( - types.ClientRequest( + result = await session.send_request( types.CallToolRequest( params=types.CallToolRequestParams(name="wait_for_lock_with_notification", arguments={}), - ) - ), - types.CallToolResult, - metadata=metadata, - ) - assert len(result.content) == 1 - assert result.content[0].type == "text" - assert result.content[0].text == "Completed" - - # We should have received the remaining notifications - assert len(captured_notifications) == 1 + ), + types.CallToolResult, + metadata=metadata, + ) + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert result.content[0].text == "Completed" - assert isinstance(captured_notifications[0].root, types.LoggingMessageNotification) - assert captured_notifications[0].root.params.data == "Second notification after lock" + # We should have received the remaining notifications + assert len(captured_notifications) == 1 + assert isinstance(captured_notifications[0], types.LoggingMessageNotification) + assert captured_notifications[0].params.data == "Second notification after lock" @pytest.mark.anyio @@ -1371,7 +1352,7 @@ async def test_streamablehttp_server_sampling(basic_server: None, basic_server_u # Define sampling callback that returns a mock response async def sampling_callback( - context: RequestContext[ClientSession, Any], + context: RequestContext[ClientSession], params: types.CreateMessageRequestParams, ) -> types.CreateMessageResult: nonlocal sampling_callback_invoked, captured_message_params @@ -1387,20 +1368,12 @@ async def sampling_callback( text=f"Received message from server: {message_received}", ), model="test-model", - stopReason="endTurn", + stop_reason="endTurn", ) # Create client with sampling callback - async with streamablehttp_client(f"{basic_server_url}/mcp") as ( - read_stream, - write_stream, - _, - ): - async with ClientSession( - read_stream, - write_stream, - sampling_callback=sampling_callback, - ) as session: + async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream, sampling_callback=sampling_callback) as session: # Initialize the session result = await session.initialize() assert isinstance(result, InitializeResult) @@ -1421,69 +1394,68 @@ async def sampling_callback( # Context-aware server implementation for testing request context propagation -class ContextAwareServerTest(Server): # pragma: no cover - def __init__(self): - super().__init__("ContextAwareServer") - - @self.list_tools() - async def handle_list_tools() -> list[Tool]: - return [ - Tool( - name="echo_headers", - description="Echo request headers from context", - inputSchema={"type": "object", "properties": {}}, - ), - Tool( - name="echo_context", - description="Echo request context with custom data", - inputSchema={ - "type": "object", - "properties": { - "request_id": {"type": "string"}, - }, - "required": ["request_id"], +async def _handle_context_list_tools( # pragma: no cover + ctx: ServerRequestContext, params: PaginatedRequestParams | None +) -> ListToolsResult: + return ListToolsResult( + tools=[ + Tool( + name="echo_headers", + description="Echo request headers from context", + input_schema={"type": "object", "properties": {}}, + ), + Tool( + name="echo_context", + description="Echo request context with custom data", + input_schema={ + "type": "object", + "properties": { + "request_id": {"type": "string"}, }, - ), - ] + "required": ["request_id"], + }, + ), + ] + ) + + +async def _handle_context_call_tool( # pragma: no cover + ctx: ServerRequestContext, params: CallToolRequestParams +) -> CallToolResult: + name = params.name + args = params.arguments or {} + + if name == "echo_headers": + headers_info: dict[str, Any] = {} + if ctx.request and isinstance(ctx.request, Request): + headers_info = dict(ctx.request.headers) + return CallToolResult(content=[TextContent(type="text", text=json.dumps(headers_info))]) + + elif name == "echo_context": + context_data: dict[str, Any] = { + "request_id": args.get("request_id"), + "headers": {}, + "method": None, + "path": None, + } + if ctx.request and isinstance(ctx.request, Request): + request = ctx.request + context_data["headers"] = dict(request.headers) + context_data["method"] = request.method + context_data["path"] = request.url.path + return CallToolResult(content=[TextContent(type="text", text=json.dumps(context_data))]) - @self.call_tool() - async def handle_call_tool(name: str, args: dict[str, Any]) -> list[TextContent]: - ctx = self.request_context - - if name == "echo_headers": - # Access the request object from context - headers_info = {} - if ctx.request and isinstance(ctx.request, Request): - headers_info = dict(ctx.request.headers) - return [TextContent(type="text", text=json.dumps(headers_info))] - - elif name == "echo_context": - # Return full context information - context_data: dict[str, Any] = { - "request_id": args.get("request_id"), - "headers": {}, - "method": None, - "path": None, - } - if ctx.request and isinstance(ctx.request, Request): - request = ctx.request - context_data["headers"] = dict(request.headers) - context_data["method"] = request.method - context_data["path"] = request.url.path - return [ - TextContent( - type="text", - text=json.dumps(context_data), - ) - ] - - return [TextContent(type="text", text=f"Unknown tool: {name}")] + return CallToolResult(content=[TextContent(type="text", text=f"Unknown tool: {name}")]) # Server runner for context-aware testing def run_context_aware_server(port: int): # pragma: no cover """Run the context-aware test server.""" - server = ContextAwareServerTest() + server = Server( + "ContextAwareServer", + on_list_tools=_handle_context_list_tools, + on_call_tool=_handle_context_call_tool, + ) session_manager = StreamableHTTPSessionManager( app=server, @@ -1536,28 +1508,28 @@ async def test_streamablehttp_request_context_propagation(context_aware_server: "X-Trace-Id": "trace-123", } - async with streamablehttp_client(f"{basic_server_url}/mcp", headers=custom_headers) as ( - read_stream, - write_stream, - _, - ): - async with ClientSession(read_stream, write_stream) as session: - result = await session.initialize() - assert isinstance(result, InitializeResult) - assert result.serverInfo.name == "ContextAwareServer" + async with create_mcp_http_client(headers=custom_headers) as httpx_client: + async with streamable_http_client(f"{basic_server_url}/mcp", http_client=httpx_client) as ( + read_stream, + write_stream, + ): + async with ClientSession(read_stream, write_stream) as session: # pragma: no branch + result = await session.initialize() + assert isinstance(result, InitializeResult) + assert result.server_info.name == "ContextAwareServer" - # Call the tool that echoes headers back - tool_result = await session.call_tool("echo_headers", {}) + # Call the tool that echoes headers back + tool_result = await session.call_tool("echo_headers", {}) - # Parse the JSON response - assert len(tool_result.content) == 1 - assert isinstance(tool_result.content[0], TextContent) - headers_data = json.loads(tool_result.content[0].text) + # Parse the JSON response + assert len(tool_result.content) == 1 + assert isinstance(tool_result.content[0], TextContent) + headers_data = json.loads(tool_result.content[0].text) - # Verify headers were propagated - assert headers_data.get("authorization") == "Bearer test-token" - assert headers_data.get("x-custom-header") == "test-value" - assert headers_data.get("x-trace-id") == "trace-123" + # Verify headers were propagated + assert headers_data.get("authorization") == "Bearer test-token" + assert headers_data.get("x-custom-header") == "test-value" + assert headers_data.get("x-trace-id") == "trace-123" @pytest.mark.anyio @@ -1573,21 +1545,25 @@ async def test_streamablehttp_request_context_isolation(context_aware_server: No "Authorization": f"Bearer token-{i}", } - async with streamablehttp_client(f"{basic_server_url}/mcp", headers=headers) as (read_stream, write_stream, _): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() + async with create_mcp_http_client(headers=headers) as httpx_client: + async with streamable_http_client(f"{basic_server_url}/mcp", http_client=httpx_client) as ( + read_stream, + write_stream, + ): + async with ClientSession(read_stream, write_stream) as session: # pragma: no branch + await session.initialize() - # Call the tool that echoes context - tool_result = await session.call_tool("echo_context", {"request_id": f"request-{i}"}) + # Call the tool that echoes context + tool_result = await session.call_tool("echo_context", {"request_id": f"request-{i}"}) - assert len(tool_result.content) == 1 - assert isinstance(tool_result.content[0], TextContent) - context_data = json.loads(tool_result.content[0].text) - contexts.append(context_data) + assert len(tool_result.content) == 1 + assert isinstance(tool_result.content[0], TextContent) + context_data = json.loads(tool_result.content[0].text) + contexts.append(context_data) # Verify each request had its own context - assert len(contexts) == 3 # pragma: no cover - for i, ctx in enumerate(contexts): # pragma: no cover + assert len(contexts) == 3 + for i, ctx in enumerate(contexts): assert ctx["request_id"] == f"request-{i}" assert ctx["headers"].get("x-request-id") == f"request-{i}" assert ctx["headers"].get("x-custom-value") == f"value-{i}" @@ -1597,15 +1573,11 @@ async def test_streamablehttp_request_context_isolation(context_aware_server: No @pytest.mark.anyio async def test_client_includes_protocol_version_header_after_init(context_aware_server: None, basic_server_url: str): """Test that client includes mcp-protocol-version header after initialization.""" - async with streamablehttp_client(f"{basic_server_url}/mcp") as ( - read_stream, - write_stream, - _, - ): + async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: # Initialize and get the negotiated version init_result = await session.initialize() - negotiated_version = init_result.protocolVersion + negotiated_version = init_result.protocol_version # Call a tool that echoes headers to verify the header is present tool_result = await session.call_tool("echo_headers", {}) @@ -1713,11 +1685,7 @@ async def test_client_crash_handled(basic_server: None, basic_server_url: str): # Simulate bad client that crashes after init async def bad_client(): """Client that triggers ClosedResourceError""" - async with streamablehttp_client(f"{basic_server_url}/mcp") as ( - read_stream, - write_stream, - _, - ): + async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: await session.initialize() raise Exception("client crash") @@ -1731,11 +1699,7 @@ async def bad_client(): await anyio.sleep(0.1) # Try a good client, it should still be able to connect and list tools - async with streamablehttp_client(f"{basic_server_url}/mcp") as ( - read_stream, - write_stream, - _, - ): + async with streamable_http_client(f"{basic_server_url}/mcp") as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: result = await session.initialize() assert isinstance(result, InitializeResult) @@ -1857,7 +1821,7 @@ async def test_close_sse_stream_callback_not_provided_for_old_protocol_version() ) # Create a mock message and request - mock_message = JSONRPCMessage(root=JSONRPCRequest(jsonrpc="2.0", id="test-1", method="tools/list")) + mock_message = JSONRPCRequest(jsonrpc="2.0", id="test-1", method="tools/list") mock_request = MagicMock() # Call _create_session_message with OLD protocol version @@ -1880,7 +1844,7 @@ async def test_close_sse_stream_callback_not_provided_for_old_protocol_version() @pytest.mark.anyio -async def test_streamablehttp_client_receives_priming_event( +async def test_streamable_http_client_receives_priming_event( event_server: tuple[SimpleEventStore, str], ) -> None: """Client should receive priming event (resumption token update) on POST SSE stream.""" @@ -1891,11 +1855,7 @@ async def test_streamablehttp_client_receives_priming_event( async def on_resumption_token_update(token: str) -> None: captured_resumption_tokens.append(token) - async with streamablehttp_client(f"{server_url}/mcp") as ( - read_stream, - write_stream, - _, - ): + async with streamable_http_client(f"{server_url}/mcp") as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: await session.initialize() @@ -1904,11 +1864,7 @@ async def on_resumption_token_update(token: str) -> None: on_resumption_token_update=on_resumption_token_update, ) result = await session.send_request( - types.ClientRequest( - types.CallToolRequest( - params=types.CallToolRequestParams(name="test_tool", arguments={}), - ) - ), + types.CallToolRequest(params=types.CallToolRequestParams(name="test_tool", arguments={})), types.CallToolResult, metadata=metadata, ) @@ -1932,11 +1888,7 @@ async def test_server_close_sse_stream_via_context( """Server tool can call ctx.close_sse_stream() to close connection.""" _, server_url = event_server - async with streamablehttp_client(f"{server_url}/mcp") as ( - read_stream, - write_stream, - _, - ): + async with streamable_http_client(f"{server_url}/mcp") as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: await session.initialize() @@ -1953,7 +1905,7 @@ async def test_server_close_sse_stream_via_context( @pytest.mark.anyio -async def test_streamablehttp_client_auto_reconnects( +async def test_streamable_http_client_auto_reconnects( event_server: tuple[SimpleEventStore, str], ) -> None: """Client should auto-reconnect with Last-Event-ID when server closes after priming event.""" @@ -1966,19 +1918,11 @@ async def message_handler( if isinstance(message, Exception): # pragma: no branch return # pragma: no cover if isinstance(message, types.ServerNotification): # pragma: no branch - if isinstance(message.root, types.LoggingMessageNotification): # pragma: no branch - captured_notifications.append(str(message.root.params.data)) - - async with streamablehttp_client(f"{server_url}/mcp") as ( - read_stream, - write_stream, - _, - ): - async with ClientSession( - read_stream, - write_stream, - message_handler=message_handler, - ) as session: + if isinstance(message, types.LoggingMessageNotification): # pragma: no branch + captured_notifications.append(str(message.params.data)) + + async with streamable_http_client(f"{server_url}/mcp") as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session: await session.initialize() # Call tool that: @@ -1998,17 +1942,13 @@ async def message_handler( @pytest.mark.anyio -async def test_streamablehttp_client_respects_retry_interval( +async def test_streamable_http_client_respects_retry_interval( event_server: tuple[SimpleEventStore, str], ) -> None: """Client MUST respect retry field, waiting specified ms before reconnecting.""" _, server_url = event_server - async with streamablehttp_client(f"{server_url}/mcp") as ( - read_stream, - write_stream, - _, - ): + async with streamable_http_client(f"{server_url}/mcp") as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: await session.initialize() @@ -2029,7 +1969,7 @@ async def test_streamablehttp_client_respects_retry_interval( @pytest.mark.anyio -async def test_streamablehttp_sse_polling_full_cycle( +async def test_streamable_http_sse_polling_full_cycle( event_server: tuple[SimpleEventStore, str], ) -> None: """End-to-end test: server closes stream, client reconnects, receives all events.""" @@ -2042,19 +1982,11 @@ async def message_handler( if isinstance(message, Exception): # pragma: no branch return # pragma: no cover if isinstance(message, types.ServerNotification): # pragma: no branch - if isinstance(message.root, types.LoggingMessageNotification): # pragma: no branch - all_notifications.append(str(message.root.params.data)) - - async with streamablehttp_client(f"{server_url}/mcp") as ( - read_stream, - write_stream, - _, - ): - async with ClientSession( - read_stream, - write_stream, - message_handler=message_handler, - ) as session: + if isinstance(message, types.LoggingMessageNotification): # pragma: no branch + all_notifications.append(str(message.params.data)) + + async with streamable_http_client(f"{server_url}/mcp") as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session: await session.initialize() # Call tool that simulates polling pattern: @@ -2077,7 +2009,7 @@ async def message_handler( @pytest.mark.anyio -async def test_streamablehttp_events_replayed_after_disconnect( +async def test_streamable_http_events_replayed_after_disconnect( event_server: tuple[SimpleEventStore, str], ) -> None: """Events sent while client is disconnected should be replayed on reconnect.""" @@ -2090,19 +2022,11 @@ async def message_handler( if isinstance(message, Exception): # pragma: no branch return # pragma: no cover if isinstance(message, types.ServerNotification): # pragma: no branch - if isinstance(message.root, types.LoggingMessageNotification): # pragma: no branch - notification_data.append(str(message.root.params.data)) - - async with streamablehttp_client(f"{server_url}/mcp") as ( - read_stream, - write_stream, - _, - ): - async with ClientSession( - read_stream, - write_stream, - message_handler=message_handler, - ) as session: + if isinstance(message, types.LoggingMessageNotification): # pragma: no branch + notification_data.append(str(message.params.data)) + + async with streamable_http_client(f"{server_url}/mcp") as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session: await session.initialize() # Tool sends: notification1, close_stream, notification2, notification3, response @@ -2125,7 +2049,7 @@ async def message_handler( @pytest.mark.anyio -async def test_streamablehttp_multiple_reconnections( +async def test_streamable_http_multiple_reconnections( event_server: tuple[SimpleEventStore, str], ): """Verify multiple close_sse_stream() calls each trigger a client reconnect. @@ -2145,22 +2069,20 @@ async def test_streamablehttp_multiple_reconnections( async def on_resumption_token(token: str) -> None: resumption_tokens.append(token) - async with streamablehttp_client(f"{server_url}/mcp") as (read_stream, write_stream, _): + async with streamable_http_client(f"{server_url}/mcp") as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: await session.initialize() # Use send_request with metadata to track resumption tokens metadata = ClientMessageMetadata(on_resumption_token_update=on_resumption_token) result = await session.send_request( - types.ClientRequest( - types.CallToolRequest( - method="tools/call", - params=types.CallToolRequestParams( - name="tool_with_multiple_stream_closes", - # retry_interval=500ms, so sleep 600ms to ensure reconnect completes - arguments={"checkpoints": 3, "sleep_time": 0.6}, - ), - ) + types.CallToolRequest( + method="tools/call", + params=types.CallToolRequestParams( + name="tool_with_multiple_stream_closes", + # retry_interval=500ms, so sleep 600ms to ensure reconnect completes + arguments={"checkpoints": 3, "sleep_time": 0.6}, + ), ), types.CallToolResult, metadata=metadata, @@ -2171,18 +2093,15 @@ async def on_resumption_token(token: str) -> None: assert "Completed 3 checkpoints" in result.content[0].text # 4 priming + 3 notifications + 1 response = 8 tokens - assert len(resumption_tokens) == 8, ( # pragma: no cover + assert len(resumption_tokens) == 8, ( # pragma: lax no cover f"Expected 8 resumption tokens (4 priming + 3 notifs + 1 response), " f"got {len(resumption_tokens)}: {resumption_tokens}" ) @pytest.mark.anyio -async def test_standalone_get_stream_reconnection( - event_server: tuple[SimpleEventStore, str], -) -> None: - """ - Test that standalone GET stream automatically reconnects after server closes it. +async def test_standalone_get_stream_reconnection(event_server: tuple[SimpleEventStore, str]) -> None: + """Test that standalone GET stream automatically reconnects after server closes it. Verifies: 1. Client receives notification 1 via GET stream @@ -2202,19 +2121,11 @@ async def message_handler( if isinstance(message, Exception): return # pragma: no cover if isinstance(message, types.ServerNotification): # pragma: no branch - if isinstance(message.root, types.ResourceUpdatedNotification): # pragma: no branch - received_notifications.append(str(message.root.params.uri)) - - async with streamablehttp_client(f"{server_url}/mcp") as ( - read_stream, - write_stream, - _, - ): - async with ClientSession( - read_stream, - write_stream, - message_handler=message_handler, - ) as session: + if isinstance(message, types.ResourceUpdatedNotification): # pragma: no branch + received_notifications.append(str(message.params.uri)) + + async with streamable_http_client(f"{server_url}/mcp") as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session: await session.initialize() # Call tool that: @@ -2230,9 +2141,109 @@ async def message_handler( assert result.content[0].text == "Standalone stream close test done" # Verify both notifications were received - assert "http://notification_1/" in received_notifications, ( + assert "http://notification_1" in received_notifications, ( f"Should receive notification 1 (sent before GET stream close), got: {received_notifications}" ) - assert "http://notification_2/" in received_notifications, ( + assert "http://notification_2" in received_notifications, ( f"Should receive notification 2 after reconnect, got: {received_notifications}" ) + + +@pytest.mark.anyio +async def test_streamable_http_client_does_not_mutate_provided_client( + basic_server: None, basic_server_url: str +) -> None: + """Test that streamable_http_client does not mutate the provided httpx client's headers.""" + # Create a client with custom headers + original_headers = { + "X-Custom-Header": "custom-value", + "Authorization": "Bearer test-token", + } + + async with httpx.AsyncClient(headers=original_headers, follow_redirects=True) as custom_client: + # Use the client with streamable_http_client + async with streamable_http_client(f"{basic_server_url}/mcp", http_client=custom_client) as ( + read_stream, + write_stream, + ): + async with ClientSession(read_stream, write_stream) as session: + result = await session.initialize() + assert isinstance(result, InitializeResult) + + # Verify client headers were not mutated with MCP protocol headers + # If accept header exists, it should still be httpx default, not MCP's + if "accept" in custom_client.headers: # pragma: no branch + assert custom_client.headers.get("accept") == "*/*" + # MCP content-type should not have been added + assert custom_client.headers.get("content-type") != "application/json" + + # Verify custom headers are still present and unchanged + assert custom_client.headers.get("X-Custom-Header") == "custom-value" + assert custom_client.headers.get("Authorization") == "Bearer test-token" + + +@pytest.mark.anyio +async def test_streamable_http_client_mcp_headers_override_defaults( + context_aware_server: None, basic_server_url: str +) -> None: + """Test that MCP protocol headers override httpx.AsyncClient default headers.""" + # httpx.AsyncClient has default "accept: */*" header + # We need to verify that our MCP accept header overrides it in actual requests + + async with httpx.AsyncClient(follow_redirects=True) as client: + # Verify client has default accept header + assert client.headers.get("accept") == "*/*" + + async with streamable_http_client(f"{basic_server_url}/mcp", http_client=client) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: # pragma: no branch + await session.initialize() + + # Use echo_headers tool to see what headers the server actually received + tool_result = await session.call_tool("echo_headers", {}) + assert len(tool_result.content) == 1 + assert isinstance(tool_result.content[0], TextContent) + headers_data = json.loads(tool_result.content[0].text) + + # Verify MCP protocol headers were sent (not httpx defaults) + assert "accept" in headers_data + assert "application/json" in headers_data["accept"] + assert "text/event-stream" in headers_data["accept"] + + assert "content-type" in headers_data + assert headers_data["content-type"] == "application/json" + + +@pytest.mark.anyio +async def test_streamable_http_client_preserves_custom_with_mcp_headers( + context_aware_server: None, basic_server_url: str +) -> None: + """Test that both custom headers and MCP protocol headers are sent in requests.""" + custom_headers = { + "X-Custom-Header": "custom-value", + "X-Request-Id": "req-123", + "Authorization": "Bearer test-token", + } + + async with httpx.AsyncClient(headers=custom_headers, follow_redirects=True) as client: + async with streamable_http_client(f"{basic_server_url}/mcp", http_client=client) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: # pragma: no branch + await session.initialize() + + # Use echo_headers tool to verify both custom and MCP headers are present + tool_result = await session.call_tool("echo_headers", {}) + assert len(tool_result.content) == 1 + assert isinstance(tool_result.content[0], TextContent) + headers_data = json.loads(tool_result.content[0].text) + + # Verify custom headers are present + assert headers_data.get("x-custom-header") == "custom-value" + assert headers_data.get("x-request-id") == "req-123" + assert headers_data.get("authorization") == "Bearer test-token" + + # Verify MCP protocol headers are also present + assert "accept" in headers_data + assert "application/json" in headers_data["accept"] + assert "text/event-stream" in headers_data["accept"] + + assert "content-type" in headers_data + assert headers_data["content-type"] == "application/json" diff --git a/tests/shared/test_tool_name_validation.py b/tests/shared/test_tool_name_validation.py index 4746f3f9f..97b3dffcd 100644 --- a/tests/shared/test_tool_name_validation.py +++ b/tests/shared/test_tool_name_validation.py @@ -10,190 +10,199 @@ validate_tool_name, ) +# Tests for validate_tool_name function - valid names + + +@pytest.mark.parametrize( + "tool_name", + [ + "getUser", + "get_user_profile", + "user-profile-update", + "admin.tools.list", + "DATA_EXPORT_v2.1", + "a", + "a" * 128, + ], + ids=[ + "simple_alphanumeric", + "with_underscores", + "with_dashes", + "with_dots", + "mixed_characters", + "single_character", + "max_length_128", + ], +) +def test_validate_tool_name_accepts_valid_names(tool_name: str) -> None: + """Valid tool names should pass validation with no warnings.""" + result = validate_tool_name(tool_name) + assert result.is_valid is True + assert result.warnings == [] + + +# Tests for validate_tool_name function - invalid names + + +def test_validate_tool_name_rejects_empty_name() -> None: + """Empty names should be rejected.""" + result = validate_tool_name("") + assert result.is_valid is False + assert "Tool name cannot be empty" in result.warnings + + +def test_validate_tool_name_rejects_name_exceeding_max_length() -> None: + """Names exceeding 128 characters should be rejected.""" + result = validate_tool_name("a" * 129) + assert result.is_valid is False + assert any("exceeds maximum length of 128 characters (current: 129)" in w for w in result.warnings) + + +@pytest.mark.parametrize( + "tool_name,expected_char", + [ + ("get user profile", "' '"), + ("get,user,profile", "','"), + ("user/profile/update", "'/'"), + ("user@domain.com", "'@'"), + ], + ids=[ + "with_spaces", + "with_commas", + "with_slashes", + "with_at_symbol", + ], +) +def test_validate_tool_name_rejects_invalid_characters(tool_name: str, expected_char: str) -> None: + """Names with invalid characters should be rejected.""" + result = validate_tool_name(tool_name) + assert result.is_valid is False + assert any("invalid characters" in w and expected_char in w for w in result.warnings) + + +def test_validate_tool_name_rejects_multiple_invalid_chars() -> None: + """Names with multiple invalid chars should list all of them.""" + result = validate_tool_name("user name@domain,com") + assert result.is_valid is False + warning = next(w for w in result.warnings if "invalid characters" in w) + assert "' '" in warning + assert "'@'" in warning + assert "','" in warning + + +def test_validate_tool_name_rejects_unicode_characters() -> None: + """Names with unicode characters should be rejected.""" + result = validate_tool_name("user-\u00f1ame") # n with tilde + assert result.is_valid is False + + +# Tests for validate_tool_name function - warnings for problematic patterns + + +def test_validate_tool_name_warns_on_leading_dash() -> None: + """Names starting with dash should generate warning but be valid.""" + result = validate_tool_name("-get-user") + assert result.is_valid is True + assert any("starts or ends with a dash" in w for w in result.warnings) + + +def test_validate_tool_name_warns_on_trailing_dash() -> None: + """Names ending with dash should generate warning but be valid.""" + result = validate_tool_name("get-user-") + assert result.is_valid is True + assert any("starts or ends with a dash" in w for w in result.warnings) + + +def test_validate_tool_name_warns_on_leading_dot() -> None: + """Names starting with dot should generate warning but be valid.""" + result = validate_tool_name(".get.user") + assert result.is_valid is True + assert any("starts or ends with a dot" in w for w in result.warnings) + + +def test_validate_tool_name_warns_on_trailing_dot() -> None: + """Names ending with dot should generate warning but be valid.""" + result = validate_tool_name("get.user.") + assert result.is_valid is True + assert any("starts or ends with a dot" in w for w in result.warnings) + + +# Tests for issue_tool_name_warning function + + +def test_issue_tool_name_warning_logs_warnings(caplog: pytest.LogCaptureFixture) -> None: + """Warnings should be logged at WARNING level.""" + warnings = ["Warning 1", "Warning 2"] + with caplog.at_level(logging.WARNING): + issue_tool_name_warning("test-tool", warnings) -class TestValidateToolName: - """Tests for validate_tool_name function.""" - - class TestValidNames: - """Test cases for valid tool names.""" - - @pytest.mark.parametrize( - "tool_name", - [ - "getUser", - "get_user_profile", - "user-profile-update", - "admin.tools.list", - "DATA_EXPORT_v2.1", - "a", - "a" * 128, - ], - ids=[ - "simple_alphanumeric", - "with_underscores", - "with_dashes", - "with_dots", - "mixed_characters", - "single_character", - "max_length_128", - ], - ) - def test_accepts_valid_names(self, tool_name: str) -> None: - """Valid tool names should pass validation with no warnings.""" - result = validate_tool_name(tool_name) - assert result.is_valid is True - assert result.warnings == [] - - class TestInvalidNames: - """Test cases for invalid tool names.""" - - def test_rejects_empty_name(self) -> None: - """Empty names should be rejected.""" - result = validate_tool_name("") - assert result.is_valid is False - assert "Tool name cannot be empty" in result.warnings - - def test_rejects_name_exceeding_max_length(self) -> None: - """Names exceeding 128 characters should be rejected.""" - result = validate_tool_name("a" * 129) - assert result.is_valid is False - assert any("exceeds maximum length of 128 characters (current: 129)" in w for w in result.warnings) - - @pytest.mark.parametrize( - "tool_name,expected_char", - [ - ("get user profile", "' '"), - ("get,user,profile", "','"), - ("user/profile/update", "'/'"), - ("user@domain.com", "'@'"), - ], - ids=[ - "with_spaces", - "with_commas", - "with_slashes", - "with_at_symbol", - ], - ) - def test_rejects_invalid_characters(self, tool_name: str, expected_char: str) -> None: - """Names with invalid characters should be rejected.""" - result = validate_tool_name(tool_name) - assert result.is_valid is False - assert any("invalid characters" in w and expected_char in w for w in result.warnings) - - def test_rejects_multiple_invalid_chars(self) -> None: - """Names with multiple invalid chars should list all of them.""" - result = validate_tool_name("user name@domain,com") - assert result.is_valid is False - warning = next(w for w in result.warnings if "invalid characters" in w) - assert "' '" in warning - assert "'@'" in warning - assert "','" in warning - - def test_rejects_unicode_characters(self) -> None: - """Names with unicode characters should be rejected.""" - result = validate_tool_name("user-\u00f1ame") # n with tilde - assert result.is_valid is False - - class TestWarningsForProblematicPatterns: - """Test cases for valid names that generate warnings.""" - - def test_warns_on_leading_dash(self) -> None: - """Names starting with dash should generate warning but be valid.""" - result = validate_tool_name("-get-user") - assert result.is_valid is True - assert any("starts or ends with a dash" in w for w in result.warnings) - - def test_warns_on_trailing_dash(self) -> None: - """Names ending with dash should generate warning but be valid.""" - result = validate_tool_name("get-user-") - assert result.is_valid is True - assert any("starts or ends with a dash" in w for w in result.warnings) - - def test_warns_on_leading_dot(self) -> None: - """Names starting with dot should generate warning but be valid.""" - result = validate_tool_name(".get.user") - assert result.is_valid is True - assert any("starts or ends with a dot" in w for w in result.warnings) - - def test_warns_on_trailing_dot(self) -> None: - """Names ending with dot should generate warning but be valid.""" - result = validate_tool_name("get.user.") - assert result.is_valid is True - assert any("starts or ends with a dot" in w for w in result.warnings) - - -class TestIssueToolNameWarning: - """Tests for issue_tool_name_warning function.""" - - def test_logs_warnings(self, caplog: pytest.LogCaptureFixture) -> None: - """Warnings should be logged at WARNING level.""" - warnings = ["Warning 1", "Warning 2"] - with caplog.at_level(logging.WARNING): - issue_tool_name_warning("test-tool", warnings) - - assert 'Tool name validation warning for "test-tool"' in caplog.text - assert "- Warning 1" in caplog.text - assert "- Warning 2" in caplog.text - assert "Tool registration will proceed" in caplog.text - assert "SEP-986" in caplog.text - - def test_no_logging_for_empty_warnings(self, caplog: pytest.LogCaptureFixture) -> None: - """Empty warnings list should not produce any log output.""" - with caplog.at_level(logging.WARNING): - issue_tool_name_warning("test-tool", []) - - assert caplog.text == "" - - -class TestValidateAndWarnToolName: - """Tests for validate_and_warn_tool_name function.""" - - def test_returns_true_for_valid_name(self) -> None: - """Valid names should return True.""" - assert validate_and_warn_tool_name("valid-tool-name") is True - - def test_returns_false_for_invalid_name(self) -> None: - """Invalid names should return False.""" - assert validate_and_warn_tool_name("") is False - assert validate_and_warn_tool_name("a" * 129) is False - assert validate_and_warn_tool_name("invalid name") is False - - def test_logs_warnings_for_invalid_name(self, caplog: pytest.LogCaptureFixture) -> None: - """Invalid names should trigger warning logs.""" - with caplog.at_level(logging.WARNING): - validate_and_warn_tool_name("invalid name") - - assert "Tool name validation warning" in caplog.text - - def test_no_warnings_for_clean_valid_name(self, caplog: pytest.LogCaptureFixture) -> None: - """Clean valid names should not produce any log output.""" - with caplog.at_level(logging.WARNING): - result = validate_and_warn_tool_name("clean-tool-name") - - assert result is True - assert caplog.text == "" - - -class TestEdgeCases: - """Test edge cases and robustness.""" - - @pytest.mark.parametrize( - "tool_name,is_valid,expected_warning_fragment", - [ - ("...", True, "starts or ends with a dot"), - ("---", True, "starts or ends with a dash"), - ("///", False, "invalid characters"), - ("user@name123", False, "invalid characters"), - ], - ids=[ - "only_dots", - "only_dashes", - "only_slashes", - "mixed_valid_invalid", - ], - ) - def test_edge_cases(self, tool_name: str, is_valid: bool, expected_warning_fragment: str) -> None: - """Various edge cases should be handled correctly.""" - result = validate_tool_name(tool_name) - assert result.is_valid is is_valid - assert any(expected_warning_fragment in w for w in result.warnings) + assert 'Tool name validation warning for "test-tool"' in caplog.text + assert "- Warning 1" in caplog.text + assert "- Warning 2" in caplog.text + assert "Tool registration will proceed" in caplog.text + assert "SEP-986" in caplog.text + + +def test_issue_tool_name_warning_no_logging_for_empty_warnings(caplog: pytest.LogCaptureFixture) -> None: + """Empty warnings list should not produce any log output.""" + with caplog.at_level(logging.WARNING): + issue_tool_name_warning("test-tool", []) + + assert caplog.text == "" + + +# Tests for validate_and_warn_tool_name function + + +def test_validate_and_warn_tool_name_returns_true_for_valid_name() -> None: + """Valid names should return True.""" + assert validate_and_warn_tool_name("valid-tool-name") is True + + +def test_validate_and_warn_tool_name_returns_false_for_invalid_name() -> None: + """Invalid names should return False.""" + assert validate_and_warn_tool_name("") is False + assert validate_and_warn_tool_name("a" * 129) is False + assert validate_and_warn_tool_name("invalid name") is False + + +def test_validate_and_warn_tool_name_logs_warnings_for_invalid_name(caplog: pytest.LogCaptureFixture) -> None: + """Invalid names should trigger warning logs.""" + with caplog.at_level(logging.WARNING): + validate_and_warn_tool_name("invalid name") + + assert "Tool name validation warning" in caplog.text + + +def test_validate_and_warn_tool_name_no_warnings_for_clean_valid_name(caplog: pytest.LogCaptureFixture) -> None: + """Clean valid names should not produce any log output.""" + with caplog.at_level(logging.WARNING): + result = validate_and_warn_tool_name("clean-tool-name") + + assert result is True + assert caplog.text == "" + + +# Tests for edge cases + + +@pytest.mark.parametrize( + "tool_name,is_valid,expected_warning_fragment", + [ + ("...", True, "starts or ends with a dot"), + ("---", True, "starts or ends with a dash"), + ("///", False, "invalid characters"), + ("user@name123", False, "invalid characters"), + ], + ids=[ + "only_dots", + "only_dashes", + "only_slashes", + "mixed_valid_invalid", + ], +) +def test_edge_cases(tool_name: str, is_valid: bool, expected_warning_fragment: str) -> None: + """Various edge cases should be handled correctly.""" + result = validate_tool_name(tool_name) + assert result.is_valid is is_valid + assert any(expected_warning_fragment in w for w in result.warnings) diff --git a/tests/shared/test_ws.py b/tests/shared/test_ws.py index f093cb492..9addb661d 100644 --- a/tests/shared/test_ws.py +++ b/tests/shared/test_ws.py @@ -1,26 +1,28 @@ import multiprocessing import socket -import time from collections.abc import AsyncGenerator, Generator -from typing import Any +from urllib.parse import urlparse import anyio import pytest import uvicorn -from pydantic import AnyUrl from starlette.applications import Starlette from starlette.routing import WebSocketRoute from starlette.websockets import WebSocket +from mcp import MCPError from mcp.client.session import ClientSession from mcp.client.websocket import websocket_client -from mcp.server import Server +from mcp.server import Server, ServerRequestContext from mcp.server.websocket import websocket_server -from mcp.shared.exceptions import McpError from mcp.types import ( + CallToolRequestParams, + CallToolResult, EmptyResult, - ErrorData, InitializeResult, + ListToolsResult, + PaginatedRequestParams, + ReadResourceRequestParams, ReadResourceResult, TextContent, TextResourceContents, @@ -43,52 +45,65 @@ def server_url(server_port: int) -> str: return f"ws://127.0.0.1:{server_port}" -# Test server implementation -class ServerTest(Server): # pragma: no cover - def __init__(self): - super().__init__(SERVER_NAME) - - @self.read_resource() - async def handle_read_resource(uri: AnyUrl) -> str | bytes: - if uri.scheme == "foobar": - return f"Read {uri.host}" - elif uri.scheme == "slow": - # Simulate a slow resource - await anyio.sleep(2.0) - return f"Slow response from {uri.host}" - - raise McpError(error=ErrorData(code=404, message="OOPS! no resource with that URI was found")) - - @self.list_tools() - async def handle_list_tools() -> list[Tool]: - return [ - Tool( - name="test_tool", - description="A test tool", - inputSchema={"type": "object", "properties": {}}, +async def handle_read_resource( # pragma: no cover + ctx: ServerRequestContext, params: ReadResourceRequestParams +) -> ReadResourceResult: + parsed = urlparse(str(params.uri)) + if parsed.scheme == "foobar": + return ReadResourceResult( + contents=[TextResourceContents(uri=str(params.uri), text=f"Read {parsed.netloc}", mime_type="text/plain")] + ) + elif parsed.scheme == "slow": + await anyio.sleep(2.0) + return ReadResourceResult( + contents=[ + TextResourceContents( + uri=str(params.uri), text=f"Slow response from {parsed.netloc}", mime_type="text/plain" ) ] + ) + raise MCPError(code=404, message="OOPS! no resource with that URI was found") + + +async def handle_list_tools( # pragma: no cover + ctx: ServerRequestContext, params: PaginatedRequestParams | None +) -> ListToolsResult: + return ListToolsResult( + tools=[ + Tool( + name="test_tool", + description="A test tool", + input_schema={"type": "object", "properties": {}}, + ) + ] + ) + - @self.call_tool() - async def handle_call_tool(name: str, args: dict[str, Any]) -> list[TextContent]: - return [TextContent(type="text", text=f"Called {name}")] +async def handle_call_tool( # pragma: no cover + ctx: ServerRequestContext, params: CallToolRequestParams +) -> CallToolResult: + return CallToolResult(content=[TextContent(type="text", text=f"Called {params.name}")]) + + +def _create_server() -> Server: # pragma: no cover + return Server( + SERVER_NAME, + on_read_resource=handle_read_resource, + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, + ) # Test fixtures def make_server_app() -> Starlette: # pragma: no cover """Create test Starlette app with WebSocket transport""" - server = ServerTest() + server = _create_server() async def handle_ws(websocket: WebSocket): async with websocket_server(websocket.scope, websocket.receive, websocket.send) as streams: await server.run(streams[0], streams[1], server.create_initialization_options()) - app = Starlette( - routes=[ - WebSocketRoute("/ws", endpoint=handle_ws), - ] - ) - + app = Starlette(routes=[WebSocketRoute("/ws", endpoint=handle_ws)]) return app @@ -98,13 +113,8 @@ def run_server(server_port: int) -> None: # pragma: no cover print(f"starting server on {server_port}") server.run() - # Give server time to start - while not server.started: - print("waiting for server to start") - time.sleep(0.5) - -@pytest.fixture() # pragma: no cover +@pytest.fixture() def server(server_port: int) -> Generator[None, None, None]: proc = multiprocessing.Process(target=run_server, kwargs={"server_port": server_port}, daemon=True) print("starting process") @@ -132,7 +142,7 @@ async def initialized_ws_client_session(server: None, server_url: str) -> AsyncG # Test initialization result = await session.initialize() assert isinstance(result, InitializeResult) - assert result.serverInfo.name == SERVER_NAME + assert result.server_info.name == SERVER_NAME # Test ping ping_result = await session.send_ping() @@ -150,7 +160,7 @@ async def test_ws_client_basic_connection(server: None, server_url: str) -> None # Test initialization result = await session.initialize() assert isinstance(result, InitializeResult) - assert result.serverInfo.name == SERVER_NAME + assert result.server_info.name == SERVER_NAME # Test ping ping_result = await session.send_ping() @@ -162,7 +172,7 @@ async def test_ws_client_happy_request_and_response( initialized_ws_client_session: ClientSession, ) -> None: """Test a successful request and response via WebSocket""" - result = await initialized_ws_client_session.read_resource(AnyUrl("foobar://example")) + result = await initialized_ws_client_session.read_resource("foobar://example") assert isinstance(result, ReadResourceResult) assert isinstance(result.contents, list) assert len(result.contents) > 0 @@ -175,8 +185,8 @@ async def test_ws_client_exception_handling( initialized_ws_client_session: ClientSession, ) -> None: """Test exception handling in WebSocket communication""" - with pytest.raises(McpError) as exc_info: - await initialized_ws_client_session.read_resource(AnyUrl("unknown://example")) + with pytest.raises(MCPError) as exc_info: + await initialized_ws_client_session.read_resource("unknown://example") assert exc_info.value.error.code == 404 @@ -188,11 +198,11 @@ async def test_ws_client_timeout( # Set a very short timeout to trigger a timeout exception with pytest.raises(TimeoutError): with anyio.fail_after(0.1): # 100ms timeout - await initialized_ws_client_session.read_resource(AnyUrl("slow://example")) + await initialized_ws_client_session.read_resource("slow://example") # Now test that we can still use the session after a timeout with anyio.fail_after(5): # Longer timeout to allow completion - result = await initialized_ws_client_session.read_resource(AnyUrl("foobar://example")) + result = await initialized_ws_client_session.read_resource("foobar://example") assert isinstance(result, ReadResourceResult) assert isinstance(result.contents, list) assert len(result.contents) > 0 diff --git a/tests/test_examples.py b/tests/test_examples.py index 6f5464e39..aa9de0957 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -6,85 +6,81 @@ # pyright: reportUnknownMemberType=false import sys +from pathlib import Path import pytest +from inline_snapshot import snapshot from pytest_examples import CodeExample, EvalExample, find_examples -from mcp.shared.memory import create_connected_server_and_client_session as client_session -from mcp.types import TextContent, TextResourceContents +from mcp import Client +from mcp.types import CallToolResult, TextContent, TextResourceContents @pytest.mark.anyio async def test_simple_echo(): """Test the simple echo server""" - from examples.fastmcp.simple_echo import mcp + from examples.mcpserver.simple_echo import mcp - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.call_tool("echo", {"text": "hello"}) - assert len(result.content) == 1 - content = result.content[0] - assert isinstance(content, TextContent) - assert content.text == "hello" + assert result == snapshot( + CallToolResult(content=[TextContent(text="hello")], structured_content={"result": "hello"}) + ) @pytest.mark.anyio async def test_complex_inputs(): """Test the complex inputs server""" - from examples.fastmcp.complex_inputs import mcp + from examples.mcpserver.complex_inputs import mcp - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: tank = {"shrimp": [{"name": "bob"}, {"name": "alice"}]} result = await client.call_tool("name_shrimp", {"tank": tank, "extra_names": ["charlie"]}) - assert len(result.content) == 3 - assert isinstance(result.content[0], TextContent) - assert isinstance(result.content[1], TextContent) - assert isinstance(result.content[2], TextContent) - assert result.content[0].text == "bob" - assert result.content[1].text == "alice" - assert result.content[2].text == "charlie" + assert result == snapshot( + CallToolResult( + content=[ + TextContent(text="bob"), + TextContent(text="alice"), + TextContent(text="charlie"), + ], + structured_content={"result": ["bob", "alice", "charlie"]}, + ) + ) @pytest.mark.anyio async def test_direct_call_tool_result_return(): """Test the CallToolResult echo server""" - from examples.fastmcp.direct_call_tool_result_return import mcp + from examples.mcpserver.direct_call_tool_result_return import mcp - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.call_tool("echo", {"text": "hello"}) - assert len(result.content) == 1 - content = result.content[0] - assert isinstance(content, TextContent) - assert content.text == "hello" - assert result.structuredContent - assert result.structuredContent["text"] == "hello" - assert isinstance(result.meta, dict) - assert result.meta["some"] == "metadata" + assert result == snapshot( + CallToolResult( + meta={"some": "metadata"}, # type: ignore[reportUnknownMemberType] + content=[TextContent(text="hello")], + structured_content={"text": "hello"}, + ) + ) @pytest.mark.anyio async def test_desktop(monkeypatch: pytest.MonkeyPatch): """Test the desktop server""" - from pathlib import Path - - from pydantic import AnyUrl - - from examples.fastmcp.desktop import mcp - # Mock desktop directory listing mock_files = [Path("/fake/path/file1.txt"), Path("/fake/path/file2.txt")] monkeypatch.setattr(Path, "iterdir", lambda self: mock_files) # type: ignore[reportUnknownArgumentType] monkeypatch.setattr(Path, "home", lambda: Path("/fake/home")) - async with client_session(mcp._mcp_server) as client: + from examples.mcpserver.desktop import mcp + + async with Client(mcp) as client: # Test the sum function result = await client.call_tool("sum", {"a": 1, "b": 2}) - assert len(result.content) == 1 - content = result.content[0] - assert isinstance(content, TextContent) - assert content.text == "3" + assert result == snapshot(CallToolResult(content=[TextContent(text="3")], structured_content={"result": 3})) # Test the desktop resource - result = await client.read_resource(AnyUrl("dir://desktop")) + result = await client.read_resource("dir://desktop") assert len(result.contents) == 1 content = result.contents[0] assert isinstance(content, TextResourceContents) @@ -95,12 +91,13 @@ async def test_desktop(monkeypatch: pytest.MonkeyPatch): assert file_1 in content.text assert file_2 in content.text # might be a bug, but the test is passing - else: # pragma: no cover + else: # pragma: lax no cover assert "/fake/path/file1.txt" in content.text assert "/fake/path/file2.txt" in content.text -@pytest.mark.parametrize("example", find_examples("README.md"), ids=str) +# TODO(v2): Change back to README.md when v2 is released +@pytest.mark.parametrize("example", find_examples("README.v2.md"), ids=str) def test_docs_examples(example: CodeExample, eval_example: EvalExample): ruff_ignore: list[str] = ["F841", "I001", "F821"] # F821: undefined names (snippets lack imports) diff --git a/tests/test_types.py b/tests/test_types.py index 1c16c3cc6..f424efdbf 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -5,14 +5,12 @@ from mcp.types import ( LATEST_PROTOCOL_VERSION, ClientCapabilities, - ClientRequest, CreateMessageRequestParams, CreateMessageResult, CreateMessageResultWithTools, Implementation, InitializeRequest, InitializeRequestParams, - JSONRPCMessage, JSONRPCRequest, ListToolsResult, SamplingCapability, @@ -22,6 +20,8 @@ ToolChoice, ToolResultContent, ToolUseContent, + client_request_adapter, + jsonrpc_message_adapter, ) @@ -38,28 +38,27 @@ async def test_jsonrpc_request(): }, } - request = JSONRPCMessage.model_validate(json_data) - assert isinstance(request.root, JSONRPCRequest) - ClientRequest.model_validate(request.model_dump(by_alias=True, exclude_none=True)) + request = jsonrpc_message_adapter.validate_python(json_data) + assert isinstance(request, JSONRPCRequest) + client_request_adapter.validate_python(request.model_dump(by_alias=True, exclude_none=True)) - assert request.root.jsonrpc == "2.0" - assert request.root.id == 1 - assert request.root.method == "initialize" - assert request.root.params is not None - assert request.root.params["protocolVersion"] == LATEST_PROTOCOL_VERSION + assert request.jsonrpc == "2.0" + assert request.id == 1 + assert request.method == "initialize" + assert request.params is not None + assert request.params["protocolVersion"] == LATEST_PROTOCOL_VERSION @pytest.mark.anyio async def test_method_initialization(): - """ - Test that the method is automatically set on object creation. + """Test that the method is automatically set on object creation. Testing just for InitializeRequest to keep the test simple, but should be set for other types as well. """ initialize_request = InitializeRequest( params=InitializeRequestParams( - protocolVersion=LATEST_PROTOCOL_VERSION, + protocol_version=LATEST_PROTOCOL_VERSION, capabilities=ClientCapabilities(), - clientInfo=Implementation( + client_info=Implementation( name="mcp", version="0.1.0", ), @@ -68,7 +67,7 @@ async def test_method_initialization(): assert initialize_request.method == "initialize", "method should be set to 'initialize'" assert initialize_request.params is not None - assert initialize_request.params.protocolVersion == LATEST_PROTOCOL_VERSION + assert initialize_request.params.protocol_version == LATEST_PROTOCOL_VERSION @pytest.mark.anyio @@ -105,9 +104,9 @@ async def test_tool_result_content(): tool_result = ToolResultContent.model_validate(tool_result_data) assert tool_result.type == "tool_result" - assert tool_result.toolUseId == "call_abc123" + assert tool_result.tool_use_id == "call_abc123" assert len(tool_result.content) == 1 - assert tool_result.isError is False + assert tool_result.is_error is False # Test with empty content (should default to []) minimal_result_data = {"type": "tool_result", "toolUseId": "call_xyz"} @@ -221,21 +220,21 @@ async def test_create_message_request_params_with_tools(): tool = Tool( name="get_weather", description="Get weather information", - inputSchema={"type": "object", "properties": {"location": {"type": "string"}}}, + input_schema={"type": "object", "properties": {"location": {"type": "string"}}}, ) params = CreateMessageRequestParams( messages=[SamplingMessage(role="user", content=TextContent(type="text", text="What's the weather?"))], - maxTokens=1000, + max_tokens=1000, tools=[tool], - toolChoice=ToolChoice(mode="auto"), + tool_choice=ToolChoice(mode="auto"), ) assert params.tools is not None assert len(params.tools) == 1 assert params.tools[0].name == "get_weather" - assert params.toolChoice is not None - assert params.toolChoice.mode == "auto" + assert params.tool_choice is not None + assert params.tool_choice.mode == "auto" @pytest.mark.anyio @@ -252,7 +251,7 @@ async def test_create_message_result_with_tool_use(): result = CreateMessageResultWithTools.model_validate(result_data) assert result.role == "assistant" assert isinstance(result.content, ToolUseContent) - assert result.stopReason == "toolUse" + assert result.stop_reason == "toolUse" assert result.model == "claude-3" # Test content_as_list with single content (covers else branch) @@ -276,7 +275,7 @@ async def test_create_message_result_basic(): assert result.role == "assistant" assert isinstance(result.content, TextContent) assert result.content.text == "Hello!" - assert result.stopReason == "endTurn" + assert result.stop_reason == "endTurn" assert result.model == "claude-3" @@ -322,13 +321,13 @@ def test_tool_preserves_json_schema_2020_12_fields(): "additionalProperties": False, } - tool = Tool(name="test_tool", description="A test tool", inputSchema=input_schema) + tool = Tool(name="test_tool", description="A test tool", input_schema=input_schema) # Verify fields are preserved in the model - assert tool.inputSchema["$schema"] == "https://json-schema.org/draft/2020-12/schema" - assert "$defs" in tool.inputSchema - assert "address" in tool.inputSchema["$defs"] - assert tool.inputSchema["additionalProperties"] is False + assert tool.input_schema["$schema"] == "https://json-schema.org/draft/2020-12/schema" + assert "$defs" in tool.input_schema + assert "address" in tool.input_schema["$defs"] + assert tool.input_schema["additionalProperties"] is False # Verify fields survive serialization round-trip serialized = tool.model_dump(mode="json", by_alias=True) @@ -358,6 +357,6 @@ def test_list_tools_result_preserves_json_schema_2020_12_fields(): result = ListToolsResult.model_validate(raw_response) tool = result.tools[0] - assert tool.inputSchema["$schema"] == "https://json-schema.org/draft/2020-12/schema" - assert "$defs" in tool.inputSchema - assert tool.inputSchema["additionalProperties"] is False + assert tool.input_schema["$schema"] == "https://json-schema.org/draft/2020-12/schema" + assert "$defs" in tool.input_schema + assert tool.input_schema["additionalProperties"] is False diff --git a/uv.lock b/uv.lock index 757709acd..d01d510f1 100644 --- a/uv.lock +++ b/uv.lock @@ -1,11 +1,14 @@ version = 1 revision = 3 requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] [manifest] members = [ "mcp", - "mcp-conformance-auth-client", "mcp-everything-server", "mcp-simple-auth", "mcp-simple-auth-client", @@ -331,101 +334,101 @@ wheels = [ [[package]] name = "coverage" -version = "7.10.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, - { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, - { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, - { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, - { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, - { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, - { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, - { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, - { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, - { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, - { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, - { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, - { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, - { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, - { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, - { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, - { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, - { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, - { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, - { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, - { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, - { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, - { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, - { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, - { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, - { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, - { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, - { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, - { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, - { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, - { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, - { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, - { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, - { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, - { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, - { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, - { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, - { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, - { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, - { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, - { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, - { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, - { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, - { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, - { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, - { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, - { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, - { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, - { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, - { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, - { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, - { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, - { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, - { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, - { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, - { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, - { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, - { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, - { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, - { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, - { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, - { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, - { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, - { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, - { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, - { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, - { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, - { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, - { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, - { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, - { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, - { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, - { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, - { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, - { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, +version = "7.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/45/2c665ca77ec32ad67e25c77daf1cee28ee4558f3bc571cdbaf88a00b9f23/coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936", size = 820905, upload-time = "2025-12-08T13:14:38.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/08/bdd7ccca14096f7eb01412b87ac11e5d16e4cb54b6e328afc9dee8bdaec1/coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070", size = 217979, upload-time = "2025-12-08T13:12:14.505Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/d1302e3416298a28b5663ae1117546a745d9d19fde7e28402b2c5c3e2109/coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98", size = 218496, upload-time = "2025-12-08T13:12:16.237Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/d36c354c8b2a320819afcea6bffe72839efd004b98d1d166b90801d49d57/coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5", size = 245237, upload-time = "2025-12-08T13:12:17.858Z" }, + { url = "https://files.pythonhosted.org/packages/91/52/be5e85631e0eec547873d8b08dd67a5f6b111ecfe89a86e40b89b0c1c61c/coverage-7.13.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e", size = 247061, upload-time = "2025-12-08T13:12:19.132Z" }, + { url = "https://files.pythonhosted.org/packages/0f/45/a5e8fa0caf05fbd8fa0402470377bff09cc1f026d21c05c71e01295e55ab/coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33", size = 248928, upload-time = "2025-12-08T13:12:20.702Z" }, + { url = "https://files.pythonhosted.org/packages/f5/42/ffb5069b6fd1b95fae482e02f3fecf380d437dd5a39bae09f16d2e2e7e01/coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791", size = 245931, upload-time = "2025-12-08T13:12:22.243Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/73e809b882c2858f13e55c0c36e94e09ce07e6165d5644588f9517efe333/coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032", size = 246968, upload-time = "2025-12-08T13:12:23.52Z" }, + { url = "https://files.pythonhosted.org/packages/87/08/64ebd9e64b6adb8b4a4662133d706fbaccecab972e0b3ccc23f64e2678ad/coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9", size = 244972, upload-time = "2025-12-08T13:12:24.781Z" }, + { url = "https://files.pythonhosted.org/packages/12/97/f4d27c6fe0cb375a5eced4aabcaef22de74766fb80a3d5d2015139e54b22/coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f", size = 245241, upload-time = "2025-12-08T13:12:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/0c/94/42f8ae7f633bf4c118bf1038d80472f9dade88961a466f290b81250f7ab7/coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8", size = 245847, upload-time = "2025-12-08T13:12:29.337Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/6369ca22b6b6d933f4f4d27765d313d8914cc4cce84f82a16436b1a233db/coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f", size = 220573, upload-time = "2025-12-08T13:12:30.905Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dc/a6a741e519acceaeccc70a7f4cfe5d030efc4b222595f0677e101af6f1f3/coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303", size = 221509, upload-time = "2025-12-08T13:12:32.09Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dc/888bf90d8b1c3d0b4020a40e52b9f80957d75785931ec66c7dfaccc11c7d/coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820", size = 218104, upload-time = "2025-12-08T13:12:33.333Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ea/069d51372ad9c380214e86717e40d1a743713a2af191cfba30a0911b0a4a/coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f", size = 218606, upload-time = "2025-12-08T13:12:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/68/09/77b1c3a66c2aa91141b6c4471af98e5b1ed9b9e6d17255da5eb7992299e3/coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96", size = 248999, upload-time = "2025-12-08T13:12:36.02Z" }, + { url = "https://files.pythonhosted.org/packages/0a/32/2e2f96e9d5691eaf1181d9040f850b8b7ce165ea10810fd8e2afa534cef7/coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259", size = 250925, upload-time = "2025-12-08T13:12:37.221Z" }, + { url = "https://files.pythonhosted.org/packages/7b/45/b88ddac1d7978859b9a39a8a50ab323186148f1d64bc068f86fc77706321/coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb", size = 253032, upload-time = "2025-12-08T13:12:38.763Z" }, + { url = "https://files.pythonhosted.org/packages/71/cb/e15513f94c69d4820a34b6bf3d2b1f9f8755fa6021be97c7065442d7d653/coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9", size = 249134, upload-time = "2025-12-08T13:12:40.382Z" }, + { url = "https://files.pythonhosted.org/packages/09/61/d960ff7dc9e902af3310ce632a875aaa7860f36d2bc8fc8b37ee7c1b82a5/coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030", size = 250731, upload-time = "2025-12-08T13:12:41.992Z" }, + { url = "https://files.pythonhosted.org/packages/98/34/c7c72821794afc7c7c2da1db8f00c2c98353078aa7fb6b5ff36aac834b52/coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833", size = 248795, upload-time = "2025-12-08T13:12:43.331Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5b/e0f07107987a43b2def9aa041c614ddb38064cbf294a71ef8c67d43a0cdd/coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8", size = 248514, upload-time = "2025-12-08T13:12:44.546Z" }, + { url = "https://files.pythonhosted.org/packages/71/c2/c949c5d3b5e9fc6dd79e1b73cdb86a59ef14f3709b1d72bf7668ae12e000/coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753", size = 249424, upload-time = "2025-12-08T13:12:45.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/f1/bbc009abd6537cec0dffb2cc08c17a7f03de74c970e6302db4342a6e05af/coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b", size = 220597, upload-time = "2025-12-08T13:12:47.378Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/d9977f2fb51c10fbaed0718ce3d0a8541185290b981f73b1d27276c12d91/coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe", size = 221536, upload-time = "2025-12-08T13:12:48.7Z" }, + { url = "https://files.pythonhosted.org/packages/be/ad/3fcf43fd96fb43e337a3073dea63ff148dcc5c41ba7a14d4c7d34efb2216/coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7", size = 220206, upload-time = "2025-12-08T13:12:50.365Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f1/2619559f17f31ba00fc40908efd1fbf1d0a5536eb75dc8341e7d660a08de/coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf", size = 218274, upload-time = "2025-12-08T13:12:52.095Z" }, + { url = "https://files.pythonhosted.org/packages/2b/11/30d71ae5d6e949ff93b2a79a2c1b4822e00423116c5c6edfaeef37301396/coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f", size = 218638, upload-time = "2025-12-08T13:12:53.418Z" }, + { url = "https://files.pythonhosted.org/packages/79/c2/fce80fc6ded8d77e53207489d6065d0fed75db8951457f9213776615e0f5/coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb", size = 250129, upload-time = "2025-12-08T13:12:54.744Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b6/51b5d1eb6fcbb9a1d5d6984e26cbe09018475c2922d554fd724dd0f056ee/coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621", size = 252885, upload-time = "2025-12-08T13:12:56.401Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/972a5affea41de798691ab15d023d3530f9f56a72e12e243f35031846ff7/coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74", size = 253974, upload-time = "2025-12-08T13:12:57.718Z" }, + { url = "https://files.pythonhosted.org/packages/8a/56/116513aee860b2c7968aa3506b0f59b22a959261d1dbf3aea7b4450a7520/coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57", size = 250538, upload-time = "2025-12-08T13:12:59.254Z" }, + { url = "https://files.pythonhosted.org/packages/d6/75/074476d64248fbadf16dfafbf93fdcede389ec821f74ca858d7c87d2a98c/coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8", size = 251912, upload-time = "2025-12-08T13:13:00.604Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d2/aa4f8acd1f7c06024705c12609d8698c51b27e4d635d717cd1934c9668e2/coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d", size = 250054, upload-time = "2025-12-08T13:13:01.892Z" }, + { url = "https://files.pythonhosted.org/packages/19/98/8df9e1af6a493b03694a1e8070e024e7d2cdc77adedc225a35e616d505de/coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b", size = 249619, upload-time = "2025-12-08T13:13:03.236Z" }, + { url = "https://files.pythonhosted.org/packages/d8/71/f8679231f3353018ca66ef647fa6fe7b77e6bff7845be54ab84f86233363/coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd", size = 251496, upload-time = "2025-12-08T13:13:04.511Z" }, + { url = "https://files.pythonhosted.org/packages/04/86/9cb406388034eaf3c606c22094edbbb82eea1fa9d20c0e9efadff20d0733/coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef", size = 220808, upload-time = "2025-12-08T13:13:06.422Z" }, + { url = "https://files.pythonhosted.org/packages/1c/59/af483673df6455795daf5f447c2f81a3d2fcfc893a22b8ace983791f6f34/coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae", size = 221616, upload-time = "2025-12-08T13:13:07.95Z" }, + { url = "https://files.pythonhosted.org/packages/64/b0/959d582572b30a6830398c60dd419c1965ca4b5fb38ac6b7093a0d50ca8d/coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080", size = 220261, upload-time = "2025-12-08T13:13:09.581Z" }, + { url = "https://files.pythonhosted.org/packages/7c/cc/bce226595eb3bf7d13ccffe154c3c487a22222d87ff018525ab4dd2e9542/coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf", size = 218297, upload-time = "2025-12-08T13:13:10.977Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9f/73c4d34600aae03447dff3d7ad1d0ac649856bfb87d1ca7d681cfc913f9e/coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a", size = 218673, upload-time = "2025-12-08T13:13:12.562Z" }, + { url = "https://files.pythonhosted.org/packages/63/ab/8fa097db361a1e8586535ae5073559e6229596b3489ec3ef2f5b38df8cb2/coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74", size = 249652, upload-time = "2025-12-08T13:13:13.909Z" }, + { url = "https://files.pythonhosted.org/packages/90/3a/9bfd4de2ff191feb37ef9465855ca56a6f2f30a3bca172e474130731ac3d/coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6", size = 252251, upload-time = "2025-12-08T13:13:15.553Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/b5d8105f016e1b5874af0d7c67542da780ccd4a5f2244a433d3e20ceb1ad/coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b", size = 253492, upload-time = "2025-12-08T13:13:16.849Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b8/0fad449981803cc47a4694768b99823fb23632150743f9c83af329bb6090/coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232", size = 249850, upload-time = "2025-12-08T13:13:18.142Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e9/8d68337c3125014d918cf4327d5257553a710a2995a6a6de2ac77e5aa429/coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971", size = 251633, upload-time = "2025-12-08T13:13:19.56Z" }, + { url = "https://files.pythonhosted.org/packages/55/14/d4112ab26b3a1bc4b3c1295d8452dcf399ed25be4cf649002fb3e64b2d93/coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d", size = 249586, upload-time = "2025-12-08T13:13:20.883Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a9/22b0000186db663b0d82f86c2f1028099ae9ac202491685051e2a11a5218/coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137", size = 249412, upload-time = "2025-12-08T13:13:22.22Z" }, + { url = "https://files.pythonhosted.org/packages/a1/2e/42d8e0d9e7527fba439acdc6ed24a2b97613b1dc85849b1dd935c2cffef0/coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511", size = 251191, upload-time = "2025-12-08T13:13:23.899Z" }, + { url = "https://files.pythonhosted.org/packages/a4/af/8c7af92b1377fd8860536aadd58745119252aaaa71a5213e5a8e8007a9f5/coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1", size = 220829, upload-time = "2025-12-08T13:13:25.182Z" }, + { url = "https://files.pythonhosted.org/packages/58/f9/725e8bf16f343d33cbe076c75dc8370262e194ff10072c0608b8e5cf33a3/coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a", size = 221640, upload-time = "2025-12-08T13:13:26.836Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ff/e98311000aa6933cc79274e2b6b94a2fe0fe3434fca778eba82003675496/coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6", size = 220269, upload-time = "2025-12-08T13:13:28.116Z" }, + { url = "https://files.pythonhosted.org/packages/cf/cf/bbaa2e1275b300343ea865f7d424cc0a2e2a1df6925a070b2b2d5d765330/coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a", size = 218990, upload-time = "2025-12-08T13:13:29.463Z" }, + { url = "https://files.pythonhosted.org/packages/21/1d/82f0b3323b3d149d7672e7744c116e9c170f4957e0c42572f0366dbb4477/coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8", size = 219340, upload-time = "2025-12-08T13:13:31.524Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e3/fe3fd4702a3832a255f4d43013eacb0ef5fc155a5960ea9269d8696db28b/coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053", size = 260638, upload-time = "2025-12-08T13:13:32.965Z" }, + { url = "https://files.pythonhosted.org/packages/ad/01/63186cb000307f2b4da463f72af9b85d380236965574c78e7e27680a2593/coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071", size = 262705, upload-time = "2025-12-08T13:13:34.378Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a1/c0dacef0cc865f2455d59eed3548573ce47ed603205ffd0735d1d78b5906/coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e", size = 265125, upload-time = "2025-12-08T13:13:35.73Z" }, + { url = "https://files.pythonhosted.org/packages/ef/92/82b99223628b61300bd382c205795533bed021505eab6dd86e11fb5d7925/coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493", size = 259844, upload-time = "2025-12-08T13:13:37.69Z" }, + { url = "https://files.pythonhosted.org/packages/cf/2c/89b0291ae4e6cd59ef042708e1c438e2290f8c31959a20055d8768349ee2/coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0", size = 262700, upload-time = "2025-12-08T13:13:39.525Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f9/a5f992efae1996245e796bae34ceb942b05db275e4b34222a9a40b9fbd3b/coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e", size = 260321, upload-time = "2025-12-08T13:13:41.172Z" }, + { url = "https://files.pythonhosted.org/packages/4c/89/a29f5d98c64fedbe32e2ac3c227fbf78edc01cc7572eee17d61024d89889/coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c", size = 259222, upload-time = "2025-12-08T13:13:43.282Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c3/940fe447aae302a6701ee51e53af7e08b86ff6eed7631e5740c157ee22b9/coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e", size = 261411, upload-time = "2025-12-08T13:13:44.72Z" }, + { url = "https://files.pythonhosted.org/packages/eb/31/12a4aec689cb942a89129587860ed4d0fd522d5fda81237147fde554b8ae/coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46", size = 221505, upload-time = "2025-12-08T13:13:46.332Z" }, + { url = "https://files.pythonhosted.org/packages/65/8c/3b5fe3259d863572d2b0827642c50c3855d26b3aefe80bdc9eba1f0af3b0/coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39", size = 222569, upload-time = "2025-12-08T13:13:47.79Z" }, + { url = "https://files.pythonhosted.org/packages/b0/39/f71fa8316a96ac72fc3908839df651e8eccee650001a17f2c78cdb355624/coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e", size = 220841, upload-time = "2025-12-08T13:13:49.243Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4b/9b54bedda55421449811dcd5263a2798a63f48896c24dfb92b0f1b0845bd/coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256", size = 218343, upload-time = "2025-12-08T13:13:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/59/df/c3a1f34d4bba2e592c8979f924da4d3d4598b0df2392fbddb7761258e3dc/coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a", size = 218672, upload-time = "2025-12-08T13:13:52.284Z" }, + { url = "https://files.pythonhosted.org/packages/07/62/eec0659e47857698645ff4e6ad02e30186eb8afd65214fd43f02a76537cb/coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9", size = 249715, upload-time = "2025-12-08T13:13:53.791Z" }, + { url = "https://files.pythonhosted.org/packages/23/2d/3c7ff8b2e0e634c1f58d095f071f52ed3c23ff25be524b0ccae8b71f99f8/coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19", size = 252225, upload-time = "2025-12-08T13:13:55.274Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ac/fb03b469d20e9c9a81093575003f959cf91a4a517b783aab090e4538764b/coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be", size = 253559, upload-time = "2025-12-08T13:13:57.161Z" }, + { url = "https://files.pythonhosted.org/packages/29/62/14afa9e792383c66cc0a3b872a06ded6e4ed1079c7d35de274f11d27064e/coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb", size = 249724, upload-time = "2025-12-08T13:13:58.692Z" }, + { url = "https://files.pythonhosted.org/packages/31/b7/333f3dab2939070613696ab3ee91738950f0467778c6e5a5052e840646b7/coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8", size = 251582, upload-time = "2025-12-08T13:14:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/81/cb/69162bda9381f39b2287265d7e29ee770f7c27c19f470164350a38318764/coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b", size = 249538, upload-time = "2025-12-08T13:14:02.556Z" }, + { url = "https://files.pythonhosted.org/packages/e0/76/350387b56a30f4970abe32b90b2a434f87d29f8b7d4ae40d2e8a85aacfb3/coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9", size = 249349, upload-time = "2025-12-08T13:14:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/86/0d/7f6c42b8d59f4c7e43ea3059f573c0dcfed98ba46eb43c68c69e52ae095c/coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927", size = 251011, upload-time = "2025-12-08T13:14:05.505Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f1/4bb2dff379721bb0b5c649d5c5eaf438462cad824acf32eb1b7ca0c7078e/coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f", size = 221091, upload-time = "2025-12-08T13:14:07.127Z" }, + { url = "https://files.pythonhosted.org/packages/ba/44/c239da52f373ce379c194b0ee3bcc121020e397242b85f99e0afc8615066/coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc", size = 221904, upload-time = "2025-12-08T13:14:08.542Z" }, + { url = "https://files.pythonhosted.org/packages/89/1f/b9f04016d2a29c2e4a0307baefefad1a4ec5724946a2b3e482690486cade/coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b", size = 220480, upload-time = "2025-12-08T13:14:10.958Z" }, + { url = "https://files.pythonhosted.org/packages/16/d4/364a1439766c8e8647860584171c36010ca3226e6e45b1753b1b249c5161/coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28", size = 219074, upload-time = "2025-12-08T13:14:13.345Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f4/71ba8be63351e099911051b2089662c03d5671437a0ec2171823c8e03bec/coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe", size = 219342, upload-time = "2025-12-08T13:14:15.02Z" }, + { url = "https://files.pythonhosted.org/packages/5e/25/127d8ed03d7711a387d96f132589057213e3aef7475afdaa303412463f22/coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657", size = 260713, upload-time = "2025-12-08T13:14:16.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/db/559fbb6def07d25b2243663b46ba9eb5a3c6586c0c6f4e62980a68f0ee1c/coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff", size = 262825, upload-time = "2025-12-08T13:14:18.68Z" }, + { url = "https://files.pythonhosted.org/packages/37/99/6ee5bf7eff884766edb43bd8736b5e1c5144d0fe47498c3779326fe75a35/coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3", size = 265233, upload-time = "2025-12-08T13:14:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/d8/90/92f18fe0356ea69e1f98f688ed80cec39f44e9f09a1f26a1bbf017cc67f2/coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b", size = 259779, upload-time = "2025-12-08T13:14:22.367Z" }, + { url = "https://files.pythonhosted.org/packages/90/5d/b312a8b45b37a42ea7d27d7d3ff98ade3a6c892dd48d1d503e773503373f/coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d", size = 262700, upload-time = "2025-12-08T13:14:24.309Z" }, + { url = "https://files.pythonhosted.org/packages/63/f8/b1d0de5c39351eb71c366f872376d09386640840a2e09b0d03973d791e20/coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e", size = 260302, upload-time = "2025-12-08T13:14:26.068Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7c/d42f4435bc40c55558b3109a39e2d456cddcec37434f62a1f1230991667a/coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940", size = 259136, upload-time = "2025-12-08T13:14:27.604Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d3/23413241dc04d47cfe19b9a65b32a2edd67ecd0b817400c2843ebc58c847/coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2", size = 261467, upload-time = "2025-12-08T13:14:29.09Z" }, + { url = "https://files.pythonhosted.org/packages/13/e6/6e063174500eee216b96272c0d1847bf215926786f85c2bd024cf4d02d2f/coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7", size = 221875, upload-time = "2025-12-08T13:14:31.106Z" }, + { url = "https://files.pythonhosted.org/packages/3b/46/f4fb293e4cbe3620e3ac2a3e8fd566ed33affb5861a9b20e3dd6c1896cbc/coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc", size = 222982, upload-time = "2025-12-08T13:14:33.1Z" }, + { url = "https://files.pythonhosted.org/packages/68/62/5b3b9018215ed9733fbd1ae3b2ed75c5de62c3b55377a52cae732e1b7805/coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a", size = 221016, upload-time = "2025-12-08T13:14:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4c/1968f32fb9a2604645827e11ff84a31e59d532e01995f904723b4f5328b3/coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904", size = 210068, upload-time = "2025-12-08T13:14:36.236Z" }, ] [package.optional-dependencies] @@ -435,62 +438,75 @@ toml = [ [[package]] name = "cryptography" -version = "45.0.5" +version = "46.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/1e/49527ac611af559665f71cbb8f92b332b5ec9c6fbc4e88b0f8e92f5e85df/cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a", size = 744903, upload-time = "2025-07-02T13:06:25.941Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/fb/09e28bc0c46d2c547085e60897fea96310574c70fb21cd58a730a45f3403/cryptography-45.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:101ee65078f6dd3e5a028d4f19c07ffa4dd22cce6a20eaa160f8b5219911e7d8", size = 7043092, upload-time = "2025-07-02T13:05:01.514Z" }, - { url = "https://files.pythonhosted.org/packages/b1/05/2194432935e29b91fb649f6149c1a4f9e6d3d9fc880919f4ad1bcc22641e/cryptography-45.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3a264aae5f7fbb089dbc01e0242d3b67dffe3e6292e1f5182122bdf58e65215d", size = 4205926, upload-time = "2025-07-02T13:05:04.741Z" }, - { url = "https://files.pythonhosted.org/packages/07/8b/9ef5da82350175e32de245646b1884fc01124f53eb31164c77f95a08d682/cryptography-45.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e74d30ec9c7cb2f404af331d5b4099a9b322a8a6b25c4632755c8757345baac5", size = 4429235, upload-time = "2025-07-02T13:05:07.084Z" }, - { url = "https://files.pythonhosted.org/packages/7c/e1/c809f398adde1994ee53438912192d92a1d0fc0f2d7582659d9ef4c28b0c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3af26738f2db354aafe492fb3869e955b12b2ef2e16908c8b9cb928128d42c57", size = 4209785, upload-time = "2025-07-02T13:05:09.321Z" }, - { url = "https://files.pythonhosted.org/packages/d0/8b/07eb6bd5acff58406c5e806eff34a124936f41a4fb52909ffa4d00815f8c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e6c00130ed423201c5bc5544c23359141660b07999ad82e34e7bb8f882bb78e0", size = 3893050, upload-time = "2025-07-02T13:05:11.069Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ef/3333295ed58d900a13c92806b67e62f27876845a9a908c939f040887cca9/cryptography-45.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:dd420e577921c8c2d31289536c386aaa30140b473835e97f83bc71ea9d2baf2d", size = 4457379, upload-time = "2025-07-02T13:05:13.32Z" }, - { url = "https://files.pythonhosted.org/packages/d9/9d/44080674dee514dbb82b21d6fa5d1055368f208304e2ab1828d85c9de8f4/cryptography-45.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d05a38884db2ba215218745f0781775806bde4f32e07b135348355fe8e4991d9", size = 4209355, upload-time = "2025-07-02T13:05:15.017Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d8/0749f7d39f53f8258e5c18a93131919ac465ee1f9dccaf1b3f420235e0b5/cryptography-45.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad0caded895a00261a5b4aa9af828baede54638754b51955a0ac75576b831b27", size = 4456087, upload-time = "2025-07-02T13:05:16.945Z" }, - { url = "https://files.pythonhosted.org/packages/09/d7/92acac187387bf08902b0bf0699816f08553927bdd6ba3654da0010289b4/cryptography-45.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9024beb59aca9d31d36fcdc1604dd9bbeed0a55bface9f1908df19178e2f116e", size = 4332873, upload-time = "2025-07-02T13:05:18.743Z" }, - { url = "https://files.pythonhosted.org/packages/03/c2/840e0710da5106a7c3d4153c7215b2736151bba60bf4491bdb421df5056d/cryptography-45.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91098f02ca81579c85f66df8a588c78f331ca19089763d733e34ad359f474174", size = 4564651, upload-time = "2025-07-02T13:05:21.382Z" }, - { url = "https://files.pythonhosted.org/packages/2e/92/cc723dd6d71e9747a887b94eb3827825c6c24b9e6ce2bb33b847d31d5eaa/cryptography-45.0.5-cp311-abi3-win32.whl", hash = "sha256:926c3ea71a6043921050eaa639137e13dbe7b4ab25800932a8498364fc1abec9", size = 2929050, upload-time = "2025-07-02T13:05:23.39Z" }, - { url = "https://files.pythonhosted.org/packages/1f/10/197da38a5911a48dd5389c043de4aec4b3c94cb836299b01253940788d78/cryptography-45.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:b85980d1e345fe769cfc57c57db2b59cff5464ee0c045d52c0df087e926fbe63", size = 3403224, upload-time = "2025-07-02T13:05:25.202Z" }, - { url = "https://files.pythonhosted.org/packages/fe/2b/160ce8c2765e7a481ce57d55eba1546148583e7b6f85514472b1d151711d/cryptography-45.0.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3562c2f23c612f2e4a6964a61d942f891d29ee320edb62ff48ffb99f3de9ae8", size = 7017143, upload-time = "2025-07-02T13:05:27.229Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e7/2187be2f871c0221a81f55ee3105d3cf3e273c0a0853651d7011eada0d7e/cryptography-45.0.5-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fcfbefc4a7f332dece7272a88e410f611e79458fab97b5efe14e54fe476f4fd", size = 4197780, upload-time = "2025-07-02T13:05:29.299Z" }, - { url = "https://files.pythonhosted.org/packages/b9/cf/84210c447c06104e6be9122661159ad4ce7a8190011669afceeaea150524/cryptography-45.0.5-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:460f8c39ba66af7db0545a8c6f2eabcbc5a5528fc1cf6c3fa9a1e44cec33385e", size = 4420091, upload-time = "2025-07-02T13:05:31.221Z" }, - { url = "https://files.pythonhosted.org/packages/3e/6a/cb8b5c8bb82fafffa23aeff8d3a39822593cee6e2f16c5ca5c2ecca344f7/cryptography-45.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9b4cf6318915dccfe218e69bbec417fdd7c7185aa7aab139a2c0beb7468c89f0", size = 4198711, upload-time = "2025-07-02T13:05:33.062Z" }, - { url = "https://files.pythonhosted.org/packages/04/f7/36d2d69df69c94cbb2473871926daf0f01ad8e00fe3986ac3c1e8c4ca4b3/cryptography-45.0.5-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2089cc8f70a6e454601525e5bf2779e665d7865af002a5dec8d14e561002e135", size = 3883299, upload-time = "2025-07-02T13:05:34.94Z" }, - { url = "https://files.pythonhosted.org/packages/82/c7/f0ea40f016de72f81288e9fe8d1f6748036cb5ba6118774317a3ffc6022d/cryptography-45.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0027d566d65a38497bc37e0dd7c2f8ceda73597d2ac9ba93810204f56f52ebc7", size = 4450558, upload-time = "2025-07-02T13:05:37.288Z" }, - { url = "https://files.pythonhosted.org/packages/06/ae/94b504dc1a3cdf642d710407c62e86296f7da9e66f27ab12a1ee6fdf005b/cryptography-45.0.5-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:be97d3a19c16a9be00edf79dca949c8fa7eff621763666a145f9f9535a5d7f42", size = 4198020, upload-time = "2025-07-02T13:05:39.102Z" }, - { url = "https://files.pythonhosted.org/packages/05/2b/aaf0adb845d5dabb43480f18f7ca72e94f92c280aa983ddbd0bcd6ecd037/cryptography-45.0.5-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7760c1c2e1a7084153a0f68fab76e754083b126a47d0117c9ed15e69e2103492", size = 4449759, upload-time = "2025-07-02T13:05:41.398Z" }, - { url = "https://files.pythonhosted.org/packages/91/e4/f17e02066de63e0100a3a01b56f8f1016973a1d67551beaf585157a86b3f/cryptography-45.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ff8728d8d890b3dda5765276d1bc6fb099252915a2cd3aff960c4c195745dd0", size = 4319991, upload-time = "2025-07-02T13:05:43.64Z" }, - { url = "https://files.pythonhosted.org/packages/f2/2e/e2dbd629481b499b14516eed933f3276eb3239f7cee2dcfa4ee6b44d4711/cryptography-45.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7259038202a47fdecee7e62e0fd0b0738b6daa335354396c6ddebdbe1206af2a", size = 4554189, upload-time = "2025-07-02T13:05:46.045Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ea/a78a0c38f4c8736287b71c2ea3799d173d5ce778c7d6e3c163a95a05ad2a/cryptography-45.0.5-cp37-abi3-win32.whl", hash = "sha256:1e1da5accc0c750056c556a93c3e9cb828970206c68867712ca5805e46dc806f", size = 2911769, upload-time = "2025-07-02T13:05:48.329Z" }, - { url = "https://files.pythonhosted.org/packages/79/b3/28ac139109d9005ad3f6b6f8976ffede6706a6478e21c889ce36c840918e/cryptography-45.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:90cb0a7bb35959f37e23303b7eed0a32280510030daba3f7fdfbb65defde6a97", size = 3390016, upload-time = "2025-07-02T13:05:50.811Z" }, - { url = "https://files.pythonhosted.org/packages/f8/8b/34394337abe4566848a2bd49b26bcd4b07fd466afd3e8cce4cb79a390869/cryptography-45.0.5-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:206210d03c1193f4e1ff681d22885181d47efa1ab3018766a7b32a7b3d6e6afd", size = 3575762, upload-time = "2025-07-02T13:05:53.166Z" }, - { url = "https://files.pythonhosted.org/packages/8b/5d/a19441c1e89afb0f173ac13178606ca6fab0d3bd3ebc29e9ed1318b507fc/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c648025b6840fe62e57107e0a25f604db740e728bd67da4f6f060f03017d5097", size = 4140906, upload-time = "2025-07-02T13:05:55.914Z" }, - { url = "https://files.pythonhosted.org/packages/4b/db/daceb259982a3c2da4e619f45b5bfdec0e922a23de213b2636e78ef0919b/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b8fa8b0a35a9982a3c60ec79905ba5bb090fc0b9addcfd3dc2dd04267e45f25e", size = 4374411, upload-time = "2025-07-02T13:05:57.814Z" }, - { url = "https://files.pythonhosted.org/packages/6a/35/5d06ad06402fc522c8bf7eab73422d05e789b4e38fe3206a85e3d6966c11/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:14d96584701a887763384f3c47f0ca7c1cce322aa1c31172680eb596b890ec30", size = 4140942, upload-time = "2025-07-02T13:06:00.137Z" }, - { url = "https://files.pythonhosted.org/packages/65/79/020a5413347e44c382ef1f7f7e7a66817cd6273e3e6b5a72d18177b08b2f/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57c816dfbd1659a367831baca4b775b2a5b43c003daf52e9d57e1d30bc2e1b0e", size = 4374079, upload-time = "2025-07-02T13:06:02.043Z" }, - { url = "https://files.pythonhosted.org/packages/9b/c5/c0e07d84a9a2a8a0ed4f865e58f37c71af3eab7d5e094ff1b21f3f3af3bc/cryptography-45.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b9e38e0a83cd51e07f5a48ff9691cae95a79bea28fe4ded168a8e5c6c77e819d", size = 3321362, upload-time = "2025-07-02T13:06:04.463Z" }, - { url = "https://files.pythonhosted.org/packages/c0/71/9bdbcfd58d6ff5084687fe722c58ac718ebedbc98b9f8f93781354e6d286/cryptography-45.0.5-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8c4a6ff8a30e9e3d38ac0539e9a9e02540ab3f827a3394f8852432f6b0ea152e", size = 3587878, upload-time = "2025-07-02T13:06:06.339Z" }, - { url = "https://files.pythonhosted.org/packages/f0/63/83516cfb87f4a8756eaa4203f93b283fda23d210fc14e1e594bd5f20edb6/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bd4c45986472694e5121084c6ebbd112aa919a25e783b87eb95953c9573906d6", size = 4152447, upload-time = "2025-07-02T13:06:08.345Z" }, - { url = "https://files.pythonhosted.org/packages/22/11/d2823d2a5a0bd5802b3565437add16f5c8ce1f0778bf3822f89ad2740a38/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:982518cd64c54fcada9d7e5cf28eabd3ee76bd03ab18e08a48cad7e8b6f31b18", size = 4386778, upload-time = "2025-07-02T13:06:10.263Z" }, - { url = "https://files.pythonhosted.org/packages/5f/38/6bf177ca6bce4fe14704ab3e93627c5b0ca05242261a2e43ef3168472540/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:12e55281d993a793b0e883066f590c1ae1e802e3acb67f8b442e721e475e6463", size = 4151627, upload-time = "2025-07-02T13:06:13.097Z" }, - { url = "https://files.pythonhosted.org/packages/38/6a/69fc67e5266bff68a91bcb81dff8fb0aba4d79a78521a08812048913e16f/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:5aa1e32983d4443e310f726ee4b071ab7569f58eedfdd65e9675484a4eb67bd1", size = 4385593, upload-time = "2025-07-02T13:06:15.689Z" }, - { url = "https://files.pythonhosted.org/packages/f6/34/31a1604c9a9ade0fdab61eb48570e09a796f4d9836121266447b0eaf7feb/cryptography-45.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e357286c1b76403dd384d938f93c46b2b058ed4dfcdce64a770f0537ed3feb6f", size = 3331106, upload-time = "2025-07-02T13:06:18.058Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, ] [[package]] name = "cssselect2" -version = "0.8.0" +version = "0.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tinycss2" }, { name = "webencodings" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/86/fd7f58fc498b3166f3a7e8e0cddb6e620fe1da35b02248b1bd59e95dbaaa/cssselect2-0.8.0.tar.gz", hash = "sha256:7674ffb954a3b46162392aee2a3a0aedb2e14ecf99fcc28644900f4e6e3e9d3a", size = 35716, upload-time = "2025-03-05T14:46:07.988Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/20/92eaa6b0aec7189fa4b75c890640e076e9e793095721db69c5c81142c2e1/cssselect2-0.9.0.tar.gz", hash = "sha256:759aa22c216326356f65e62e791d66160a0f9c91d1424e8d8adc5e74dddfc6fb", size = 35595, upload-time = "2026-02-12T17:16:39.614Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/e7/aa315e6a749d9b96c2504a1ba0ba031ba2d0517e972ce22682e3fccecb09/cssselect2-0.8.0-py3-none-any.whl", hash = "sha256:46fc70ebc41ced7a32cd42d58b1884d72ade23d21e5a4eaaf022401c13f0e76e", size = 15454, upload-time = "2025-03-05T14:46:06.463Z" }, + { url = "https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563", size = 15453, upload-time = "2026-02-12T17:16:38.317Z" }, ] [[package]] @@ -800,6 +816,8 @@ dev = [ { name = "coverage", extra = ["toml"] }, { name = "dirty-equals" }, { name = "inline-snapshot" }, + { name = "mcp", extra = ["cli", "ws"] }, + { name = "pillow" }, { name = "pyright" }, { name = "pytest" }, { name = "pytest-examples" }, @@ -807,6 +825,7 @@ dev = [ { name = "pytest-pretty" }, { name = "pytest-xdist" }, { name = "ruff" }, + { name = "strict-no-cover" }, { name = "trio" }, ] docs = [ @@ -822,17 +841,18 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27.1" }, { name = "httpx-sse", specifier = ">=0.4" }, { name = "jsonschema", specifier = ">=4.20.0" }, - { name = "pydantic", specifier = ">=2.11.0,<3.0.0" }, + { name = "pydantic", specifier = ">=2.12.0" }, { name = "pydantic-settings", specifier = ">=2.5.2" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10.1" }, { name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.0.0" }, { name = "python-multipart", specifier = ">=0.0.9" }, - { name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=310" }, + { name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=311" }, { name = "rich", marker = "extra == 'rich'", specifier = ">=13.9.4" }, - { name = "sse-starlette", specifier = ">=1.6.1" }, - { name = "starlette", specifier = ">=0.27" }, + { name = "sse-starlette", specifier = ">=3.0.0" }, + { name = "starlette", marker = "python_full_version < '3.14'", specifier = ">=0.27" }, + { name = "starlette", marker = "python_full_version >= '3.14'", specifier = ">=0.48.0" }, { name = "typer", marker = "extra == 'cli'", specifier = ">=0.16.0" }, - { name = "typing-extensions", specifier = ">=4.9.0" }, + { name = "typing-extensions", specifier = ">=4.13.0" }, { name = "typing-inspection", specifier = ">=0.4.1" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'", specifier = ">=0.31.1" }, { name = "websockets", marker = "extra == 'ws'", specifier = ">=15.0.1" }, @@ -841,9 +861,11 @@ provides-extras = ["cli", "rich", "ws"] [package.metadata.requires-dev] dev = [ - { name = "coverage", extras = ["toml"], specifier = "==7.10.7" }, + { name = "coverage", extras = ["toml"], specifier = ">=7.10.7,<=7.13" }, { name = "dirty-equals", specifier = ">=0.9.0" }, { name = "inline-snapshot", specifier = ">=0.23.0" }, + { name = "mcp", extras = ["cli", "ws"], editable = "." }, + { name = "pillow", specifier = ">=12.0" }, { name = "pyright", specifier = ">=1.1.400" }, { name = "pytest", specifier = ">=8.3.4" }, { name = "pytest-examples", specifier = ">=0.0.14" }, @@ -851,42 +873,14 @@ dev = [ { name = "pytest-pretty", specifier = ">=1.2.0" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "ruff", specifier = ">=0.8.5" }, + { name = "strict-no-cover", git = "https://github.com/pydantic/strict-no-cover" }, { name = "trio", specifier = ">=0.26.2" }, ] docs = [ { name = "mkdocs", specifier = ">=1.6.1" }, { name = "mkdocs-glightbox", specifier = ">=0.4.0" }, { name = "mkdocs-material", extras = ["imaging"], specifier = ">=9.5.45" }, - { name = "mkdocstrings-python", specifier = ">=1.12.2" }, -] - -[[package]] -name = "mcp-conformance-auth-client" -version = "0.1.0" -source = { editable = "examples/clients/conformance-auth-client" } -dependencies = [ - { name = "httpx" }, - { name = "mcp" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "httpx", specifier = ">=0.28.1" }, - { name = "mcp", editable = "." }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.379" }, - { name = "pytest", specifier = ">=8.3.3" }, - { name = "ruff", specifier = ">=0.6.9" }, + { name = "mkdocstrings-python", specifier = ">=2.0.1" }, ] [[package]] @@ -1003,7 +997,6 @@ source = { editable = "examples/clients/simple-chatbot" } dependencies = [ { name = "mcp" }, { name = "python-dotenv" }, - { name = "requests" }, { name = "uvicorn" }, ] @@ -1018,7 +1011,6 @@ dev = [ requires-dist = [ { name = "mcp", editable = "." }, { name = "python-dotenv", specifier = ">=1.0.0" }, - { name = "requests", specifier = ">=2.31.0" }, { name = "uvicorn", specifier = ">=0.32.1" }, ] @@ -1527,12 +1519,11 @@ wheels = [ [[package]] name = "mkdocs-material" -version = "9.6.19" +version = "9.7.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "babel" }, { name = "backrefs" }, - { name = "click" }, { name = "colorama" }, { name = "jinja2" }, { name = "markdown" }, @@ -1543,9 +1534,9 @@ dependencies = [ { name = "pymdown-extensions" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/44/94/eb0fca39b19c2251b16bc759860a50f232655c4377116fa9c0e7db11b82c/mkdocs_material-9.6.19.tar.gz", hash = "sha256:80e7b3f9acabfee9b1f68bd12c26e59c865b3d5bbfb505fd1344e970db02c4aa", size = 4038202, upload-time = "2025-09-07T17:46:40.468Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/57/5d3c8c9e2ff9d66dc8f63aa052eb0bac5041fecff7761d8689fe65c39c13/mkdocs_material-9.7.2.tar.gz", hash = "sha256:6776256552290b9b7a7aa002780e25b1e04bc9c3a8516b6b153e82e16b8384bd", size = 4097818, upload-time = "2026-02-18T15:53:07.763Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/23/a2551d1038bedc2771366f65ff3680bb3a89674cd7ca6140850c859f1f71/mkdocs_material-9.6.19-py3-none-any.whl", hash = "sha256:7492d2ac81952a467ca8a10cac915d6ea5c22876932f44b5a0f4f8e7d68ac06f", size = 9240205, upload-time = "2025-09-07T17:46:36.484Z" }, + { url = "https://files.pythonhosted.org/packages/cd/19/d194e75e82282b1d688f0720e21b5ac250ed64ddea333a228aaf83105f2e/mkdocs_material-9.7.2-py3-none-any.whl", hash = "sha256:9bf6f53452d4a4d527eac3cef3f92b7b6fc4931c55d57766a7d87890d47e1b92", size = 9305052, upload-time = "2026-02-18T15:53:05.221Z" }, ] [package.optional-dependencies] @@ -1582,7 +1573,7 @@ wheels = [ [[package]] name = "mkdocstrings-python" -version = "1.18.2" +version = "2.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "griffe" }, @@ -1590,9 +1581,9 @@ dependencies = [ { name = "mkdocstrings" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/ae/58ab2bfbee2792e92a98b97e872f7c003deb903071f75d8d83aa55db28fa/mkdocstrings_python-1.18.2.tar.gz", hash = "sha256:4ad536920a07b6336f50d4c6d5603316fafb1172c5c882370cbbc954770ad323", size = 207972, upload-time = "2025-08-28T16:11:19.847Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/75/d30af27a2906f00eb90143470272376d728521997800f5dce5b340ba35bc/mkdocstrings_python-2.0.1.tar.gz", hash = "sha256:843a562221e6a471fefdd4b45cc6c22d2607ccbad632879234fa9692e9cf7732", size = 199345, upload-time = "2025-12-03T14:26:11.755Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/8f/ce008599d9adebf33ed144e7736914385e8537f5fc686fdb7cceb8c22431/mkdocstrings_python-1.18.2-py3-none-any.whl", hash = "sha256:944fe6deb8f08f33fa936d538233c4036e9f53e840994f6146e8e94eb71b600d", size = 138215, upload-time = "2025-08-28T16:11:18.176Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/c5f8deba7d2cbdfa7967a716ae801aa9ca5f734b8f54fd473ef77a088dbe/mkdocstrings_python-2.0.1-py3-none-any.whl", hash = "sha256:66ecff45c5f8b71bf174e11d49afc845c2dfc7fc0ab17a86b6b337e0f24d8d90", size = 105055, upload-time = "2025-12-03T14:26:10.184Z" }, ] [[package]] @@ -1654,104 +1645,100 @@ wheels = [ [[package]] name = "pillow" -version = "11.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/5d/45a3553a253ac8763f3561371432a90bdbe6000fbdcf1397ffe502aa206c/pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860", size = 5316554, upload-time = "2025-07-01T09:13:39.342Z" }, - { url = "https://files.pythonhosted.org/packages/7c/c8/67c12ab069ef586a25a4a79ced553586748fad100c77c0ce59bb4983ac98/pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad", size = 4686548, upload-time = "2025-07-01T09:13:41.835Z" }, - { url = "https://files.pythonhosted.org/packages/2f/bd/6741ebd56263390b382ae4c5de02979af7f8bd9807346d068700dd6d5cf9/pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0", size = 5859742, upload-time = "2025-07-03T13:09:47.439Z" }, - { url = "https://files.pythonhosted.org/packages/ca/0b/c412a9e27e1e6a829e6ab6c2dca52dd563efbedf4c9c6aa453d9a9b77359/pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b", size = 7633087, upload-time = "2025-07-03T13:09:51.796Z" }, - { url = "https://files.pythonhosted.org/packages/59/9d/9b7076aaf30f5dd17e5e5589b2d2f5a5d7e30ff67a171eb686e4eecc2adf/pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50", size = 5963350, upload-time = "2025-07-01T09:13:43.865Z" }, - { url = "https://files.pythonhosted.org/packages/f0/16/1a6bf01fb622fb9cf5c91683823f073f053005c849b1f52ed613afcf8dae/pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae", size = 6631840, upload-time = "2025-07-01T09:13:46.161Z" }, - { url = "https://files.pythonhosted.org/packages/7b/e6/6ff7077077eb47fde78739e7d570bdcd7c10495666b6afcd23ab56b19a43/pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9", size = 6074005, upload-time = "2025-07-01T09:13:47.829Z" }, - { url = "https://files.pythonhosted.org/packages/c3/3a/b13f36832ea6d279a697231658199e0a03cd87ef12048016bdcc84131601/pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e", size = 6708372, upload-time = "2025-07-01T09:13:52.145Z" }, - { url = "https://files.pythonhosted.org/packages/6c/e4/61b2e1a7528740efbc70b3d581f33937e38e98ef3d50b05007267a55bcb2/pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6", size = 6277090, upload-time = "2025-07-01T09:13:53.915Z" }, - { url = "https://files.pythonhosted.org/packages/a9/d3/60c781c83a785d6afbd6a326ed4d759d141de43aa7365725cbcd65ce5e54/pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f", size = 6985988, upload-time = "2025-07-01T09:13:55.699Z" }, - { url = "https://files.pythonhosted.org/packages/9f/28/4f4a0203165eefb3763939c6789ba31013a2e90adffb456610f30f613850/pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f", size = 2422899, upload-time = "2025-07-01T09:13:57.497Z" }, - { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531, upload-time = "2025-07-01T09:13:59.203Z" }, - { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560, upload-time = "2025-07-01T09:14:01.101Z" }, - { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978, upload-time = "2025-07-03T13:09:55.638Z" }, - { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168, upload-time = "2025-07-03T13:10:00.37Z" }, - { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053, upload-time = "2025-07-01T09:14:04.491Z" }, - { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273, upload-time = "2025-07-01T09:14:06.235Z" }, - { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043, upload-time = "2025-07-01T09:14:07.978Z" }, - { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516, upload-time = "2025-07-01T09:14:10.233Z" }, - { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768, upload-time = "2025-07-01T09:14:11.921Z" }, - { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055, upload-time = "2025-07-01T09:14:13.623Z" }, - { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079, upload-time = "2025-07-01T09:14:15.268Z" }, - { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, - { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, - { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, - { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, - { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, - { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, - { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, - { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, - { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, - { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, - { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, - { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, - { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, - { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, - { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, - { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, - { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, - { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, - { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, - { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, - { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, - { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, - { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, - { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, - { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, - { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, - { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, - { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, - { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, - { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, - { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, - { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, - { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, - { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, - { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, - { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, - { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, - { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, - { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, - { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, - { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, - { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, - { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, - { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, - { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, - { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, - { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, - { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, - { url = "https://files.pythonhosted.org/packages/6f/8b/209bd6b62ce8367f47e68a218bffac88888fdf2c9fcf1ecadc6c3ec1ebc7/pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967", size = 5270556, upload-time = "2025-07-01T09:16:09.961Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e6/231a0b76070c2cfd9e260a7a5b504fb72da0a95279410fa7afd99d9751d6/pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe", size = 4654625, upload-time = "2025-07-01T09:16:11.913Z" }, - { url = "https://files.pythonhosted.org/packages/13/f4/10cf94fda33cb12765f2397fc285fa6d8eb9c29de7f3185165b702fc7386/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c", size = 4874207, upload-time = "2025-07-03T13:11:10.201Z" }, - { url = "https://files.pythonhosted.org/packages/72/c9/583821097dc691880c92892e8e2d41fe0a5a3d6021f4963371d2f6d57250/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25", size = 6583939, upload-time = "2025-07-03T13:11:15.68Z" }, - { url = "https://files.pythonhosted.org/packages/3b/8e/5c9d410f9217b12320efc7c413e72693f48468979a013ad17fd690397b9a/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27", size = 4957166, upload-time = "2025-07-01T09:16:13.74Z" }, - { url = "https://files.pythonhosted.org/packages/62/bb/78347dbe13219991877ffb3a91bf09da8317fbfcd4b5f9140aeae020ad71/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a", size = 5581482, upload-time = "2025-07-01T09:16:16.107Z" }, - { url = "https://files.pythonhosted.org/packages/d9/28/1000353d5e61498aaeaaf7f1e4b49ddb05f2c6575f9d4f9f914a3538b6e1/pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f", size = 6984596, upload-time = "2025-07-01T09:16:18.07Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566, upload-time = "2025-07-01T09:16:19.801Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618, upload-time = "2025-07-01T09:16:21.818Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248, upload-time = "2025-07-03T13:11:20.738Z" }, - { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963, upload-time = "2025-07-03T13:11:26.283Z" }, - { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170, upload-time = "2025-07-01T09:16:23.762Z" }, - { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505, upload-time = "2025-07-01T09:16:25.593Z" }, - { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" }, +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/30/5bd3d794762481f8c8ae9c80e7b76ecea73b916959eb587521358ef0b2f9/pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0", size = 5304099, upload-time = "2026-02-11T04:20:06.13Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c1/aab9e8f3eeb4490180e357955e15c2ef74b31f64790ff356c06fb6cf6d84/pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713", size = 4657880, upload-time = "2026-02-11T04:20:09.291Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0a/9879e30d56815ad529d3985aeff5af4964202425c27261a6ada10f7cbf53/pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b", size = 6222587, upload-time = "2026-02-11T04:20:10.82Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5f/a1b72ff7139e4f89014e8d451442c74a774d5c43cd938fb0a9f878576b37/pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b", size = 8027678, upload-time = "2026-02-11T04:20:12.455Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c2/c7cb187dac79a3d22c3ebeae727abee01e077c8c7d930791dc592f335153/pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4", size = 6335777, upload-time = "2026-02-11T04:20:14.441Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7b/f9b09a7804ec7336effb96c26d37c29d27225783dc1501b7d62dcef6ae25/pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4", size = 7027140, upload-time = "2026-02-11T04:20:16.387Z" }, + { url = "https://files.pythonhosted.org/packages/98/b2/2fa3c391550bd421b10849d1a2144c44abcd966daadd2f7c12e19ea988c4/pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e", size = 6449855, upload-time = "2026-02-11T04:20:18.554Z" }, + { url = "https://files.pythonhosted.org/packages/96/ff/9caf4b5b950c669263c39e96c78c0d74a342c71c4f43fd031bb5cb7ceac9/pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff", size = 7151329, upload-time = "2026-02-11T04:20:20.646Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f8/4b24841f582704da675ca535935bccb32b00a6da1226820845fac4a71136/pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40", size = 6325574, upload-time = "2026-02-11T04:20:22.43Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/9f6b01c0881d7036063aa6612ef04c0e2cad96be21325a1e92d0203f8e91/pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23", size = 7032347, upload-time = "2026-02-11T04:20:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/79/13/c7922edded3dcdaf10c59297540b72785620abc0538872c819915746757d/pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9", size = 2453457, upload-time = "2026-02-11T04:20:25.392Z" }, + { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, + { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, + { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, + { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, + { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, + { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, + { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, ] [[package]] @@ -1783,7 +1770,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.11.7" +version = "2.12.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -1791,96 +1778,127 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, ] [[package]] name = "pydantic-core" -version = "2.33.2" +version = "2.41.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload-time = "2025-04-23T18:30:43.919Z" }, - { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload-time = "2025-04-23T18:30:46.372Z" }, - { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload-time = "2025-04-23T18:30:47.591Z" }, - { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload-time = "2025-04-23T18:30:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload-time = "2025-04-23T18:30:50.907Z" }, - { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload-time = "2025-04-23T18:30:52.083Z" }, - { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload-time = "2025-04-23T18:30:53.389Z" }, - { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload-time = "2025-04-23T18:30:54.661Z" }, - { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload-time = "2025-04-23T18:30:56.11Z" }, - { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload-time = "2025-04-23T18:30:57.501Z" }, - { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload-time = "2025-04-23T18:30:58.867Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload-time = "2025-04-23T18:31:00.078Z" }, - { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload-time = "2025-04-23T18:31:01.335Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, - { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, - { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, - { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, - { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, - { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, - { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, - { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, - { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, - { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, - { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, - { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, - { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, - { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, - { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, - { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, - { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, - { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, - { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, - { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, - { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, - { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, - { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, - { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, - { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, - { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, - { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, - { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, - { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, - { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, - { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, - { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, - { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, - { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" }, - { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" }, - { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" }, - { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload-time = "2025-04-23T18:32:59.771Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload-time = "2025-04-23T18:33:04.51Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload-time = "2025-04-23T18:33:06.391Z" }, - { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload-time = "2025-04-23T18:33:08.44Z" }, - { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload-time = "2025-04-23T18:33:10.313Z" }, - { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload-time = "2025-04-23T18:33:12.224Z" }, - { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, - { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, - { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, - { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, - { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, - { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, - { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] [[package]] @@ -2039,11 +2057,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.20" +version = "0.0.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, ] [[package]] @@ -2436,16 +2454,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/da/545b75d420bb23b5d494b0517757b351963e974e79933f01e05c929f20a6/starlette-0.49.1-py3-none-any.whl", hash = "sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875", size = 74175, upload-time = "2025-10-28T17:34:09.13Z" }, ] +[[package]] +name = "strict-no-cover" +version = "0.1.1" +source = { git = "https://github.com/pydantic/strict-no-cover#7fc59da2c4dff919db2095a0f0e47101b657131d" } +dependencies = [ + { name = "pydantic" }, +] + [[package]] name = "tinycss2" -version = "1.4.0" +version = "1.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "webencodings" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085, upload-time = "2024-10-24T14:58:29.895Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610, upload-time = "2024-10-24T14:58:28.029Z" }, + { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, ] [[package]] @@ -2531,23 +2557,23 @@ wheels = [ [[package]] name = "typing-inspection" -version = "0.4.1" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] name = "urllib3" -version = "2.5.0" +version = "2.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] [[package]]