diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..307bd81b3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,140 @@ +# Development Guidelines + +## Branching Model + + + +- `main` is currently the V2 rework. Breaking changes are expected here — when removing or + replacing an API, delete it outright and document the change in + `docs/migration.md`. Do not add `@deprecated` shims or backward-compat layers + on `main`. +- `v1.x` is the release branch for the current stable line. Backport PRs target + this branch and use a `[v1.x]` title prefix. +- `README.md` is frozen at v1 (a pre-commit hook rejects edits). Edit + `README.v2.md` instead. + +## Package Management + +- ONLY use uv, NEVER pip +- Installation: `uv add ` +- Running tools: `uv run --frozen `. Always pass `--frozen` so uv doesn't + rewrite `uv.lock` as a side effect. +- Cross-version testing: `uv run --frozen --python 3.10 pytest ...` to run + against a specific interpreter (CI covers 3.10–3.14). +- Upgrading: `uv lock --upgrade-package ` +- FORBIDDEN: `uv pip install`, `@latest` syntax +- Don't raise dependency floors for CVEs alone. The `>=` constraint already + lets users upgrade. Only raise a floor when the SDK needs functionality from + the newer version, and don't add SDK code to work around a dependency's + vulnerability. See Kludex/uvicorn#2643 and python-sdk #1552 for reasoning. + +## Code Quality + +- Type hints required for all code +- Public APIs must have docstrings. When a public API raises exceptions a + caller would reasonably catch, document them in a `Raises:` section. Don't + list exceptions from argument validation or programmer error. +- `src/mcp/__init__.py` defines the public API surface via `__all__`. Adding a + symbol there is a deliberate API decision, not a convenience re-export. +- IMPORTANT: All imports go at the top of the file — inline imports hide + dependencies and obscure circular-import bugs. Only exception: when a + top-level import genuinely can't work (lazy-loading optional deps, or + tests that re-import a module). + +## Testing + +- Framework: `uv run --frozen pytest` +- Async testing: use anyio, not asyncio +- Do not use `Test` prefixed classes — write plain top-level `test_*` functions. + Legacy files still contain `Test*` classes; do NOT follow that pattern for new + tests even when adding to such a file. +- IMPORTANT: Tests should be fast and deterministic. Prefer in-memory async execution; + reach for threads only when necessary, and subprocesses only as a last resort. +- For end-to-end behavior, an in-memory `Client(server)` is usually the + cleanest approach (see `tests/client/test_client.py` for the canonical + pattern). For narrower changes, testing the function directly is fine. Use + judgment. +- Test files mirror the source tree: `src/mcp/client/stdio.py` → + `tests/client/test_stdio.py`. Add tests to the existing file for that module. +- Avoid `anyio.sleep()` with a fixed duration to wait for async operations. Instead: + - Use `anyio.Event` — set it in the callback/handler, `await event.wait()` in the test + - For stream messages, use `await stream.receive()` instead of `sleep()` + `receive_nowait()` + - Exception: `sleep()` is appropriate when testing time-based features (e.g., timeouts) +- Wrap indefinite waits (`event.wait()`, `stream.receive()`) in `anyio.fail_after(5)` to prevent hangs +- Pytest is configured with `filterwarnings = ["error"]`, so warnings fail + tests. Don't silence warnings from your own code; fix the underlying cause. + Scoped `ignore::` entries for upstream libraries are acceptable in + `pyproject.toml` with a comment explaining why. + +### Coverage + +CI requires 100% (`fail_under = 100`, `branch = true`). + +- Full check: `./scripts/test` (~23s). Runs coverage + `strict-no-cover` on the + default Python. Not identical to CI: CI runs 3.10–3.14 × {ubuntu, windows} + × {locked, lowest-direct}, and some branch-coverage quirks only surface on + specific matrix entries. +- Targeted check while iterating (~4s, deterministic): + + ```bash + uv run --frozen coverage erase + uv run --frozen coverage run -m pytest tests/path/test_foo.py + uv run --frozen coverage combine + uv run --frozen coverage report --include='src/mcp/path/foo.py' --fail-under=0 + # UV_FROZEN=1 propagates --frozen to the uv subprocess strict-no-cover spawns + UV_FROZEN=1 uv run --frozen strict-no-cover + ``` + + Partial runs can't hit 100% (coverage tracks `tests/` too), so `--fail-under=0` + and `--include` scope the report. `strict-no-cover` has no false positives on + partial runs — if your new test executes a line marked `# pragma: no cover`, + even a single-file run catches it. + +Avoid adding new `# pragma: no cover`, `# type: ignore`, or `# noqa` comments. +In tests, use `assert isinstance(x, T)` to narrow types instead of +`# type: ignore`. In library code (`src/`), a `# pragma: no cover` needs very +good reasoning — it usually means a test is missing. Audit before pushing: + +```bash +git diff origin/main... | grep -E '^\+.*(pragma|type: ignore|noqa)' +``` + +What the existing pragmas mean: + +- `# pragma: no cover` — line is never executed. CI's `strict-no-cover` (skipped + on Windows runners) fails if it IS executed. When your test starts covering + such a line, remove the pragma. +- `# pragma: lax no cover` — excluded from coverage but not checked by + `strict-no-cover`. Use for lines covered on some platforms/versions but not + others. +- `# pragma: no branch` — excludes branch arcs only. coverage.py misreports the + `->exit` arc for nested `async with` on Python 3.11+ (worse on 3.14/Windows). + +## Breaking Changes + +When making breaking changes, document them in `docs/migration.md`. 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. + +## Formatting & Type Checking + +- Format: `uv run --frozen ruff format .` +- Lint: `uv run --frozen ruff check . --fix` +- Type check: `uv run --frozen pyright` +- Pre-commit runs all of the above plus markdownlint, a `uv.lock` consistency + check, and README checks — see `.pre-commit-config.yaml` + +## Exception Handling + +- **Always use `logger.exception()` instead of `logger.error()` when catching exceptions** + - Don't include the exception in the message: `logger.exception("Failed")` not `logger.exception(f"Failed: {e}")` +- **Catch specific exceptions** where possible: + - File ops: `except (OSError, PermissionError):` + - JSON: `except json.JSONDecodeError:` + - Network: `except (ConnectionError, TimeoutError):` +- **FORBIDDEN** `except Exception:` - unless in top-level handlers diff --git a/CLAUDE.md b/CLAUDE.md index 2eee085e1..43c994c2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,174 +1 @@ -# Development Guidelines - -This document contains critical information about working with this codebase. Follow these guidelines precisely. - -## Core Development Rules - -1. Package Management - - ONLY use uv, NEVER pip - - Installation: `uv add ` - - Running tools: `uv run ` - - Upgrading: `uv lock --upgrade-package ` - - FORBIDDEN: `uv pip install`, `@latest` syntax - -2. Code Quality - - Type hints required for all code - - Public APIs must have docstrings - - 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. - - Coverage: CI requires 100% (`fail_under = 100`, `branch = true`). - - Full check: `./scripts/test` (~23s). Runs coverage + `strict-no-cover` on the - default Python. Not identical to CI: CI also runs 3.10–3.14 × {ubuntu, windows}, - and some branch-coverage quirks only surface on specific matrix entries. - - Targeted check while iterating (~4s, deterministic): - - ```bash - uv run --frozen coverage erase - uv run --frozen coverage run -m pytest tests/path/test_foo.py - uv run --frozen coverage combine - uv run --frozen coverage report --include='src/mcp/path/foo.py' --fail-under=0 - UV_FROZEN=1 uv run --frozen strict-no-cover - ``` - - Partial runs can't hit 100% (coverage tracks `tests/` too), so `--fail-under=0` - and `--include` scope the report. `strict-no-cover` has no false positives on - partial runs — if your new test executes a line marked `# pragma: no cover`, - even a single-file run catches it. - - Coverage pragmas: - - `# pragma: no cover` — line is never executed. CI's `strict-no-cover` fails if - it IS executed. When your test starts covering such a line, remove the pragma. - - `# pragma: lax no cover` — excluded from coverage but not checked by - `strict-no-cover`. Use for lines covered on some platforms/versions but not - others. - - `# pragma: no branch` — excludes branch arcs only. coverage.py misreports the - `->exit` arc for nested `async with` on Python 3.11+ (worse on 3.14/Windows). - - 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: - - ```bash - git commit --trailer "Reported-by:" - ``` - - Where `` is the name of the user. - -- For commits related to a Github issue, add - - ```bash - git commit --trailer "Github-Issue:#" - ``` - -- NEVER ever mention a `co-authored-by` or similar aspects. In particular, never - mention the tool used to create the commit message or PR. - -## Pull Requests - -- Create a detailed message of what changed. Focus on the high level description of - the problem it tries to solve, and how it is solved. Don't go into the specifics of the - code unless it adds clarity. - -- 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 - -1. Ruff - - Format: `uv run --frozen ruff format .` - - Check: `uv run --frozen ruff check .` - - Fix: `uv run --frozen ruff check . --fix` - - Critical issues: - - Line length (88 chars) - - Import sorting (I001) - - Unused imports - - Line wrapping: - - Strings: use parentheses - - Function calls: multi-line with proper indent - - Imports: try to use a single line - -2. Type Checking - - Tool: `uv run --frozen pyright` - - Requirements: - - Type narrowing for strings - - Version warnings can be ignored if checks pass - -3. Pre-commit - - Config: `.pre-commit-config.yaml` - - Runs: on git commit - - Tools: Prettier (YAML/JSON), Ruff (Python) - - Ruff updates: - - Check PyPI versions - - Update config rev - - Commit config first - -## Error Resolution - -1. CI Failures - - Fix order: - 1. Formatting - 2. Type errors - 3. Linting - - Type errors: - - Get full line context - - Check Optional types - - Add type narrowing - - Verify function signatures - -2. Common Issues - - Line length: - - Break strings with parentheses - - Multi-line function calls - - Split imports - - Types: - - Add None checks - - Narrow string types - - Match existing patterns - -3. Best Practices - - Check git status before commits - - Run formatters before type checks - - Keep changes minimal - - Follow existing patterns - - Document public APIs - - Test thoroughly - -## Exception Handling - -- **Always use `logger.exception()` instead of `logger.error()` when catching exceptions** - - Don't include the exception in the message: `logger.exception("Failed")` not `logger.exception(f"Failed: {e}")` -- **Catch specific exceptions** where possible: - - File ops: `except (OSError, PermissionError):` - - JSON: `except json.JSONDecodeError:` - - Network: `except (ConnectionError, TimeoutError):` -- **FORBIDDEN** `except Exception:` - unless in top-level handlers +@AGENTS.md diff --git a/README.v2.md b/README.v2.md index 55d867586..d0851c04e 100644 --- a/README.v2.md +++ b/README.v2.md @@ -681,11 +681,11 @@ The Context object provides the following capabilities: - `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.debug(data)` - Send debug log message +- `await ctx.info(data)` - Send info log message +- `await ctx.warning(data)` - Send warning log message +- `await ctx.error(data)` - Send error log message +- `await ctx.log(level, data, 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 diff --git a/docs/migration.md b/docs/migration.md index 3b47f9aad..466e5d2f1 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -38,6 +38,7 @@ http_client = httpx.AsyncClient( headers={"Authorization": "Bearer token"}, timeout=httpx.Timeout(30, read=300), auth=my_auth, + follow_redirects=True, ) async with http_client: @@ -48,6 +49,8 @@ async with http_client: ... ``` +v1's internal client set `follow_redirects=True`; set it explicitly when supplying your own `httpx.AsyncClient` to preserve that behavior. + ### `get_session_id` callback removed from `streamable_http_client` The `get_session_id` callback (third element of the returned tuple) has been removed from `streamable_http_client`. The function now returns a 2-tuple `(read_stream, write_stream)` instead of a 3-tuple. @@ -100,6 +103,8 @@ async with http_client: The `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters have been removed from `StreamableHTTPTransport`. Configure these on the `httpx.AsyncClient` instead (see example above). +Note: `sse_client` retains its `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters — only the streamable HTTP transport changed. + ### Removed type aliases and classes The following deprecated type aliases and classes have been removed from `mcp.types`: @@ -126,6 +131,52 @@ from mcp.types import ContentBlock, ResourceTemplateReference # Use `str` instead of `Cursor` for pagination cursors ``` +### Field names changed from camelCase to snake_case + +All Pydantic model fields in `mcp.types` now use snake_case names for Python attribute access. The JSON wire format is unchanged — serialization still uses camelCase via Pydantic aliases. + +**Before (v1):** + +```python +result = await session.call_tool("my_tool", {"x": 1}) +if result.isError: + ... + +tools = await session.list_tools() +cursor = tools.nextCursor +schema = tools.tools[0].inputSchema +``` + +**After (v2):** + +```python +result = await session.call_tool("my_tool", {"x": 1}) +if result.is_error: + ... + +tools = await session.list_tools() +cursor = tools.next_cursor +schema = tools.tools[0].input_schema +``` + +Common renames: + +| v1 (camelCase) | v2 (snake_case) | +|----------------|-----------------| +| `inputSchema` | `input_schema` | +| `outputSchema` | `output_schema` | +| `isError` | `is_error` | +| `nextCursor` | `next_cursor` | +| `mimeType` | `mime_type` | +| `structuredContent` | `structured_content` | +| `serverInfo` | `server_info` | +| `protocolVersion` | `protocol_version` | +| `uriTemplate` | `uri_template` | +| `listChanged` | `list_changed` | +| `progressToken` | `progress_token` | + +Because `populate_by_name=True` is set, the old camelCase names still work as constructor kwargs (e.g., `Tool(inputSchema={...})` is accepted), but attribute access must use snake_case (`tool.input_schema`). + ### `args` parameter removed from `ClientSessionGroup.call_tool()` The deprecated `args` parameter has been removed from `ClientSessionGroup.call_tool()`. Use `arguments` instead. @@ -225,6 +276,28 @@ except MCPError as e: from mcp import MCPError ``` +The constructor signature also changed — it now takes `code`, `message`, and optional `data` directly instead of wrapping an `ErrorData`: + +**Before (v1):** + +```python +from mcp.shared.exceptions import McpError +from mcp.types import ErrorData, INVALID_REQUEST + +raise McpError(ErrorData(code=INVALID_REQUEST, message="bad input")) +``` + +**After (v2):** + +```python +from mcp.shared.exceptions import MCPError +from mcp.types import INVALID_REQUEST + +raise MCPError(INVALID_REQUEST, "bad input") +# or, if you already have an ErrorData: +raise MCPError.from_error_data(error_data) +``` + ### `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. @@ -240,11 +313,19 @@ mcp = FastMCP("Demo") **After (v2):** ```python -from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver import MCPServer, Context mcp = MCPServer("Demo") ``` +`Context` is the type annotation for the `ctx` parameter injected into tools, resources, and prompts (see [`get_context()` removed](#mcpserverget_context-removed) below). + +All submodules under `mcp.server.fastmcp.*` are now under `mcp.server.mcpserver.*` with the same structure. Common imports: + +- `Image`, `Audio` — from `mcp.server.mcpserver` (or `.utilities.types`) +- `UserMessage`, `AssistantMessage` — from `mcp.server.mcpserver.prompts.base` +- `ToolError`, `ResourceError` — from `mcp.server.mcpserver.exceptions` + ### `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. @@ -312,6 +393,8 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app(json_response=Tru **Note:** DNS rebinding protection is automatically enabled when `host` is `127.0.0.1`, `localhost`, or `::1`. This now happens in `sse_app()` and `streamable_http_app()` instead of the constructor. +If you were mutating these via `mcp.settings` after construction (e.g., `mcp.settings.port = 9000`), pass them to `run()` / `sse_app()` / `streamable_http_app()` instead — these fields no longer exist on `Settings`. The `debug` and `log_level` parameters remain on the constructor. + ### `MCPServer.get_context()` removed `MCPServer.get_context()` has been removed. Context is now injected by the framework and passed explicitly — there is no ambient ContextVar to read from. @@ -331,6 +414,8 @@ async def my_tool(x: int) -> str: **After (v2):** ```python +from mcp.server.mcpserver import Context + @mcp.tool() async def my_tool(x: int, ctx: Context) -> str: await ctx.info("Processing...") @@ -343,6 +428,65 @@ async def my_tool(x: int, ctx: Context) -> str: The internal layers (`ToolManager.call_tool`, `Tool.run`, `Prompt.render`, `ResourceTemplate.create_resource`, etc.) now require `context` as a positional argument. +### Registering lowlevel handlers on `MCPServer` (workaround) + +`MCPServer` does not expose public APIs for `subscribe_resource`, `unsubscribe_resource`, or `set_logging_level` handlers. In v1, the workaround was to reach into the private lowlevel server and use its decorator methods: + +**Before (v1):** + +```python +@mcp._mcp_server.set_logging_level() # pyright: ignore[reportPrivateUsage] +async def handle_set_logging_level(level: str) -> None: + ... + +mcp._mcp_server.subscribe_resource()(handle_subscribe) # pyright: ignore[reportPrivateUsage] +``` + +In v2, the lowlevel `Server` no longer has decorator methods (handlers are constructor-only), so the equivalent workaround is `_add_request_handler`: + +**After (v2):** + +```python +from mcp.server import ServerRequestContext +from mcp.types import EmptyResult, SetLevelRequestParams, SubscribeRequestParams + + +async def handle_set_logging_level(ctx: ServerRequestContext, params: SetLevelRequestParams) -> EmptyResult: + ... + return EmptyResult() + + +async def handle_subscribe(ctx: ServerRequestContext, params: SubscribeRequestParams) -> EmptyResult: + ... + return EmptyResult() + + +mcp._lowlevel_server._add_request_handler("logging/setLevel", handle_set_logging_level) # pyright: ignore[reportPrivateUsage] +mcp._lowlevel_server._add_request_handler("resources/subscribe", handle_subscribe) # pyright: ignore[reportPrivateUsage] +``` + +This is a private API and may change. A public way to register these handlers on `MCPServer` is planned; until then, use this workaround or use the lowlevel `Server` directly. + +### `MCPServer`'s `Context` logging: `message` renamed to `data`, `extra` removed + +On the high-level `Context` object (`mcp.server.mcpserver.Context`), `log()`, `.debug()`, `.info()`, `.warning()`, and `.error()` now take `data: Any` instead of `message: str`, matching the MCP spec's `LoggingMessageNotificationParams.data` field which allows any JSON-serializable value. The `extra` parameter has been removed — pass structured data directly as `data`. + +The lowlevel `ServerSession.send_log_message(data: Any)` already accepted arbitrary data and is unchanged. + +`Context.log()` also now accepts all eight RFC-5424 log levels (`debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, `emergency`) via the `LoggingLevel` type, not just the four it previously allowed. + +```python +# Before +await ctx.info("Connection failed", extra={"host": "localhost", "port": 5432}) +await ctx.log(level="info", message="hello") + +# After +await ctx.info({"message": "Connection failed", "host": "localhost", "port": 5432}) +await ctx.log(level="info", data="hello") +``` + +Positional calls (`await ctx.info("hello")`) are unaffected. + ### Replace `RootModel` by union types with `TypeAdapter` validation The following union types are no longer `RootModel` subclasses: @@ -383,6 +527,22 @@ notification = server_notification_adapter.validate_python(data) # No .root access needed - notification is the actual type ``` +The same applies when constructing values — the wrapper call is no longer needed: + +**Before (v1):** + +```python +await session.send_notification(ClientNotification(InitializedNotification())) +await session.send_request(ClientRequest(PingRequest()), EmptyResult) +``` + +**After (v2):** + +```python +await session.send_notification(InitializedNotification()) +await session.send_request(PingRequest(), EmptyResult) +``` + **Available adapters:** | Union Type | Adapter | @@ -428,6 +588,8 @@ server = Server("my-server", on_call_tool=handle_call_tool) ### `RequestContext` type parameters simplified +The `mcp.shared.context` module has been removed. `RequestContext` is now split into `ClientRequestContext` (in `mcp.client.context`) and `ServerRequestContext` (in `mcp.server.context`). + 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:** @@ -458,11 +620,27 @@ ctx: ClientRequestContext server_ctx: ServerRequestContext[LifespanContextT, RequestT] ``` +The high-level `Context` class (injected into `@mcp.tool()` etc.) similarly dropped its `ServerSessionT` parameter: `Context[ServerSessionT, LifespanContextT, RequestT]` → `Context[LifespanContextT, RequestT]`. Both remaining parameters have defaults, so bare `Context` is usually sufficient: + +**Before (v1):** + +```python +async def my_tool(ctx: Context[ServerSession, None]) -> str: ... +``` + +**After (v2):** + +```python +async def my_tool(ctx: Context) -> str: ... +# or, with an explicit lifespan type: +async def my_tool(ctx: Context[MyLifespanState]) -> str: ... +``` + ### `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:** +**Before (v1):** ```python from mcp.shared.progress import progress @@ -490,6 +668,46 @@ await session.send_progress_notification( ) ``` +### `create_connected_server_and_client_session` removed + +The `create_connected_server_and_client_session` helper in `mcp.shared.memory` has been removed. Use `mcp.client.Client` instead — it accepts a `Server` or `MCPServer` instance directly and handles the in-memory transport and session setup for you. + +**Before (v1):** + +```python +from mcp.shared.memory import create_connected_server_and_client_session + +async with create_connected_server_and_client_session(server) as session: + result = await session.call_tool("my_tool", {"x": 1}) +``` + +**After (v2):** + +```python +from mcp.client import Client + +async with Client(server) as client: + result = await client.call_tool("my_tool", {"x": 1}) +``` + +`Client` accepts the same callback parameters the old helper did (`sampling_callback`, `list_roots_callback`, `logging_callback`, `message_handler`, `elicitation_callback`, `client_info`) plus `raise_exceptions` to surface server-side errors. + +If you need direct access to the underlying `ClientSession` and memory streams (e.g., for low-level transport testing), `create_client_server_memory_streams` is still available in `mcp.shared.memory`: + +```python +import anyio +from mcp.client.session import ClientSession +from mcp.shared.memory import create_client_server_memory_streams + +async with create_client_server_memory_streams() as (client_streams, server_streams): + async with anyio.create_task_group() as tg: + tg.start_soon(lambda: server.run(*server_streams, server.create_initialization_options())) + async with ClientSession(*client_streams) as session: + await session.initialize() + ... + tg.cancel_scope.cancel() +``` + ### 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. @@ -593,6 +811,8 @@ if ListToolsRequest in server.request_handlers: server = Server("my-server", on_list_tools=handle_list_tools) ``` +If you need to check whether a handler is registered, track this yourself — there is currently no public introspection API. + ### 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. @@ -645,6 +865,29 @@ server = Server("my-server", on_list_tools=handle_list_tools, on_call_tool=handl - Handlers return the full result type (e.g. `ListToolsResult`) rather than unwrapped values (e.g. `list[Tool]`). - The automatic `jsonschema` input/output validation that the old `call_tool()` decorator performed has been removed. There is no built-in replacement — if you relied on schema validation in the lowlevel server, you will need to validate inputs yourself in your handler. +**Complete handler reference:** + +All handlers receive `ctx: ServerRequestContext` as the first argument. The second argument and return type are: + +| v1 decorator | v2 constructor kwarg | `params` type | return type | +|---|---|---|---| +| `@server.list_tools()` | `on_list_tools` | `PaginatedRequestParams \| None` | `ListToolsResult` | +| `@server.call_tool()` | `on_call_tool` | `CallToolRequestParams` | `CallToolResult \| CreateTaskResult` | +| `@server.list_resources()` | `on_list_resources` | `PaginatedRequestParams \| None` | `ListResourcesResult` | +| `@server.list_resource_templates()` | `on_list_resource_templates` | `PaginatedRequestParams \| None` | `ListResourceTemplatesResult` | +| `@server.read_resource()` | `on_read_resource` | `ReadResourceRequestParams` | `ReadResourceResult` | +| `@server.subscribe_resource()` | `on_subscribe_resource` | `SubscribeRequestParams` | `EmptyResult` | +| `@server.unsubscribe_resource()` | `on_unsubscribe_resource` | `UnsubscribeRequestParams` | `EmptyResult` | +| `@server.list_prompts()` | `on_list_prompts` | `PaginatedRequestParams \| None` | `ListPromptsResult` | +| `@server.get_prompt()` | `on_get_prompt` | `GetPromptRequestParams` | `GetPromptResult` | +| `@server.completion()` | `on_completion` | `CompleteRequestParams` | `CompleteResult` | +| `@server.set_logging_level()` | `on_set_logging_level` | `SetLevelRequestParams` | `EmptyResult` | +| — | `on_ping` | `RequestParams \| None` | `EmptyResult` | +| `@server.progress_notification()` | `on_progress` | `ProgressNotificationParams` | `None` | +| — | `on_roots_list_changed` | `NotificationParams \| None` | `None` | + +All `params` and return types are importable from `mcp.types`. + **Notification handlers:** ```python @@ -694,10 +937,17 @@ Note: `params.arguments` can be `None` (the old decorator defaulted it to `{}`). **`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`: +The old decorator auto-wrapped `Iterable[ReadResourceContents]` (and the deprecated `str`/`bytes` shorthand) into `TextResourceContents`/`BlobResourceContents`, handling base64 encoding and mime-type defaulting: ```python -# Before (v1) — str/bytes auto-wrapped with mime type defaulting +# Before (v1) — Iterable[ReadResourceContents] auto-wrapped +from mcp.server.lowlevel.helper_types import ReadResourceContents + +@server.read_resource() +async def handle(uri: AnyUrl) -> Iterable[ReadResourceContents]: + return [ReadResourceContents(content="file contents", mime_type="text/plain")] + +# Before (v1) — str/bytes shorthand (already deprecated in v1) @server.read_resource() async def handle(uri: str) -> str: return "file contents" @@ -849,10 +1099,14 @@ params = CallToolRequestParams( params = CallToolRequestParams( name="my_tool", arguments={}, - _meta={"progressToken": "tok", "customField": "value"}, # OK + _meta={"my_custom_key": "value", "another": 123}, # OK ) ``` +### Context propagation preserves server-side contextvars + +Context-aware streams now propagate the sender's `contextvars.Context` to server-side request handlers for OpenTelemetry trace correlation. Server-side context variables (such as `tenant_id_var` for multi-tenancy) are preserved by merging both contexts, with server values taking precedence. See the [Multi-Tenancy Guide](multi-tenancy.md#context-merging) for details. + ## New Features ### `streamable_http_app()` available on lowlevel Server @@ -879,6 +1133,22 @@ app = server.streamable_http_app( The lowlevel `Server` also now exposes a `session_manager` property to access the `StreamableHTTPSessionManager` after calling `streamable_http_app()`. +### Multi-tenancy support + +The SDK now supports multi-tenant deployments where a single server instance serves multiple isolated tenants. Tenant identity flows from authentication tokens through sessions, request context, and into all handler invocations. + +Key additions: + +- `AccessToken.tenant_id` — carries tenant identity in OAuth tokens +- `Context.tenant_id` — available in tool, resource, and prompt handlers +- `server.add_tool(fn, tenant_id="...")`, `server.add_resource(r, tenant_id="...")`, `server.add_prompt(p, tenant_id="...")` — register tenant-scoped tools, resources, and prompts +- `server.remove_tool(name, tenant_id="...")`, `server.remove_resource(uri, tenant_id="...")`, `server.remove_prompt(name, tenant_id="...")` — remove tenant-scoped tools, resources, and prompts +- `StreamableHTTPSessionManager` — validates tenant identity on every request and prevents cross-tenant session access + +All APIs default to `tenant_id=None`, preserving backward compatibility for single-tenant servers. + +See the [Multi-Tenancy Guide](multi-tenancy.md) for details. + ## Need Help? If you encounter issues during migration: diff --git a/docs/multi-tenancy.md b/docs/multi-tenancy.md new file mode 100644 index 000000000..c523b9b26 --- /dev/null +++ b/docs/multi-tenancy.md @@ -0,0 +1,300 @@ +# Multi-Tenancy Guide + +This guide explains how to build MCP servers that safely isolate multiple tenants sharing a single server instance. Multi-tenancy ensures that tools, resources, prompts, and sessions belonging to one tenant are invisible and inaccessible to others. + +> For a complete working example, see [`examples/servers/simple-multi-tenant/`](../examples/servers/simple-multi-tenant/). + +## Overview + +In a multi-tenant deployment, a single MCP server process serves requests from multiple organizations, teams, or users (tenants). Without proper isolation, Tenant A could list or invoke Tenant B's tools, read their resources, or hijack their sessions. + +The MCP Python SDK provides built-in tenant isolation across all layers: + +- **Authentication tokens** carry a `tenant_id` field +- **Sessions** are bound to a single tenant on first authenticated request +- **Request context** propagates `tenant_id` to every handler +- **Managers** (tools, resources, prompts) use tenant-scoped storage +- **Session manager** validates tenant identity on every request + +## How It Works + +### Tenant Identification Flow + +```mermaid +flowchart TD + A["HTTP Request"] --> B["AuthContextMiddleware"] + B -->|"extracts tenant_id from AccessToken
sets tenant_id_var (contextvar)"| C["StreamableHTTPSessionManager"] + C -->|"binds new sessions to the current tenant
rejects cross-tenant session access (HTTP 404)"| D["Low-level Server
(_handle_request / _handle_notification)"] + D -->|"reads tenant_id_var
sets session.tenant_id (set-once)
populates ServerRequestContext.tenant_id"| E["MCPServer handlers
(_handle_list_tools, _handle_call_tool, etc.)"] + E -->|"passes ctx.tenant_id to managers"| F["ToolManager / ResourceManager / PromptManager"] + F -->|"looks up (tenant_id, name) in nested dict
returns only the requesting tenant's entries"| G["Response"] +``` + +### Key Components + +| Component | File | Role | +|---|---|---| +| `AccessToken.tenant_id` | `server/auth/provider.py` | Carries tenant identity in OAuth tokens | +| `tenant_id_var` | `shared/_context.py` | Transport-agnostic contextvar for tenant propagation | +| `AuthContextMiddleware` | `server/auth/middleware/auth_context.py` | Extracts tenant from auth and sets contextvar | +| `ServerSession.tenant_id` | `server/session.py` | Binds session to tenant (set-once semantics) | +| `ServerRequestContext.tenant_id` | `shared/_context.py` | Per-request tenant context for handlers | +| `Context.tenant_id` | `server/mcpserver/context.py` | High-level property for tool/resource/prompt handlers | +| `ToolManager` | `server/mcpserver/tools/tool_manager.py` | Tenant-scoped tool storage | +| `ResourceManager` | `server/mcpserver/resources/resource_manager.py` | Tenant-scoped resource storage | +| `PromptManager` | `server/mcpserver/prompts/manager.py` | Tenant-scoped prompt storage | +| `StreamableHTTPSessionManager` | `server/streamable_http_manager.py` | Validates tenant on session access | + +## Usage + +### Simple Registration of Tenant-Scoped Tools, Resources, and Prompts + +Use the `tenant_id` parameter when adding tools, resources, or prompts: + +```python +from mcp.server.mcpserver import MCPServer + +server = MCPServer("my-server") + +# Register a tool for a specific tenant +def analyze_data(query: str) -> str: + return f"Results for: {query}" + +server.add_tool(analyze_data, tenant_id="acme-corp") + +# Register a resource for a specific tenant +from mcp.server.mcpserver.resources.types import FunctionResource + +server.add_resource( + FunctionResource(uri="data://config", name="config", fn=lambda: "tenant config"), + tenant_id="acme-corp", +) + +# Register a prompt for a specific tenant +from mcp.server.mcpserver.prompts.base import Prompt + +async def onboarding_prompt() -> str: + return "Welcome to Acme Corp!" + +server.add_prompt( + Prompt.from_function(onboarding_prompt, name="onboarding"), + tenant_id="acme-corp", +) +``` + +The same name can be registered under different tenants without conflict: + +```python +server.add_tool(acme_tool, name="analyze", tenant_id="acme-corp") +server.add_tool(globex_tool, name="analyze", tenant_id="globex-inc") +``` + +> **Note:** The `@server.tool()`, `@server.resource()`, and `@server.prompt()` +> decorators always register to the global scope (`tenant_id=None`). For +> tenant-scoped registration, use the explicit `server.add_tool(fn, tenant_id=...)`, +> `server.add_resource(resource, tenant_id=...)`, and +> `server.add_prompt(prompt, tenant_id=...)` methods. Similarly, the `tools=` +> and `resources=` constructor parameters on `MCPServer` initialize items in +> global scope only. + +### Dynamic Tenant Provisioning + +Multi-tenancy enables MCP servers to operate as SaaS platforms where tenants are +provisioned and deprovisioned at runtime. Tools, resources, and prompts can be +added or removed dynamically — for example, when a tenant signs up, changes +their subscription tier, or installs a plugin. + +#### Tenant Onboarding and Offboarding + +Register a tenant's capabilities when they sign up and remove them when they leave: + +```python +def onboard_tenant(server: MCPServer, tenant_id: str, plan: str) -> None: + """Provision tools for a new tenant based on their plan.""" + + # Base tools available to all tenants + server.add_tool(search_docs, tenant_id=tenant_id) + server.add_tool(get_status, tenant_id=tenant_id) + + # Premium tools gated by plan + if plan in ("pro", "enterprise"): + server.add_tool(run_analytics, tenant_id=tenant_id) + server.add_tool(export_data, tenant_id=tenant_id) + + +def offboard_tenant(server: MCPServer, tenant_id: str) -> None: + """Remove all capabilities when a tenant is deprovisioned.""" + for tool_name in ["search_docs", "get_status", "run_analytics", "export_data"]: + try: + server.remove_tool(tool_name, tenant_id=tenant_id) + except KeyError: + pass # Tool was never provisioned (e.g. free-plan tenant) + + for uri in ["data://docs", "data://status"]: + try: + server.remove_resource(uri, tenant_id=tenant_id) + except ValueError: + pass + + for prompt_name in ["assistant"]: + try: + server.remove_prompt(prompt_name, tenant_id=tenant_id) + except ValueError: + pass +``` + +#### Plugin Systems + +Let tenants install or uninstall integrations that map to MCP tools: + +```python +def install_plugin(server: MCPServer, tenant_id: str, plugin: str) -> None: + """Install a plugin's tools for a specific tenant.""" + plugin_tools = load_plugin_tools(plugin) # Your plugin registry + for tool_fn in plugin_tools: + server.add_tool(tool_fn, tenant_id=tenant_id) + + +def uninstall_plugin(server: MCPServer, tenant_id: str, plugin: str) -> None: + """Remove a plugin's tools for a specific tenant.""" + plugin_tool_names = get_plugin_tool_names(plugin) + for name in plugin_tool_names: + server.remove_tool(name, tenant_id=tenant_id) +``` + +All dynamic changes take effect immediately — the next `list_tools`, `list_resources`, or `list_prompts` request from that tenant will reflect the updated set. Other tenants are unaffected. + +### Accessing Tenant ID in Handlers + +Inside tool, resource, or prompt handlers, access the current tenant through `Context.tenant_id`: + +```python +from mcp.server.mcpserver.context import Context + +@server.tool() +async def get_data(ctx: Context) -> str: + tenant = ctx.tenant_id # e.g., "acme-corp" or None + return f"Data for tenant: {tenant}" +``` + +### Setting Up Authentication with Tenant ID + +The `tenant_id` field on `AccessToken` is populated by your token verifier or OAuth provider. The `AuthContextMiddleware` automatically extracts `tenant_id` from the authenticated user's access token and sets the `tenant_id_var` contextvar for downstream use. + +Implement the `TokenVerifier` protocol to bridge your external identity provider with the MCP auth stack. Your `verify_token` method decodes or introspects the bearer token and returns an `AccessToken` with `tenant_id` populated. + +**Configuring your identity provider to include tenant identity in tokens:** + +Most identity providers allow you to add custom claims to access tokens. The claim name varies by provider, but common conventions include `org_id`, `tenant_id`, or a namespaced claim like `https://myapp.com/tenant_id`. Here are some examples: + +- **Duo Security**: Define a [custom user attribute](https://duo.com/docs/user-attributes) (e.g., `tenant_id`) and assign it to users via Duo Directory sync or the Admin Panel. Include this attribute as a claim in the access token issued by Duo as your IdP. +- **Auth0**: Use [Organizations](https://auth0.com/docs/manage-users/organizations) to model tenants. When a user authenticates through an organization, Auth0 automatically includes an `org_id` claim in the access token. Alternatively, use an [Action](https://auth0.com/docs/customize/actions) on the "Machine to Machine" or "Login" flow to add a custom claim based on app metadata or connection context. +- **Okta**: Add a [custom claim](https://developer.okta.com/docs/guides/customize-tokens-returned-from-okta/) to your authorization server. Map the claim value from the user's profile (e.g., `user.profile.orgId`) or from a group membership. +- **Microsoft Entra ID (Azure AD)**: Use the `tid` (tenant ID) claim that is included by default in tokens, or configure [optional claims](https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims) to add organization-specific attributes. +- **Custom JWT issuer**: Include a `tenant_id` (or equivalent) claim in the JWT payload when minting tokens. For example: `{"sub": "user-123", "tenant_id": "acme-corp", "scope": "read write"}`. + +Once your provider includes the tenant claim, extract it in your `TokenVerifier`: + +```python +from mcp.server.auth.provider import AccessToken, TokenVerifier + + +class JWTTokenVerifier(TokenVerifier): + """Verify JWTs and extract tenant_id from claims.""" + + async def verify_token(self, token: str) -> AccessToken | None: + # Decode and validate the JWT (e.g., using PyJWT or authlib) + claims = decode_and_verify_jwt(token) + if claims is None: + return None + + return AccessToken( + token=token, + client_id=claims["sub"], + scopes=claims.get("scope", "").split(), + expires_at=claims.get("exp"), + tenant_id=claims["org_id"], # Extract tenant from your JWT claims + ) +``` + +Then pass the verifier when creating your `MCPServer`: + +```python +from mcp.server.auth.settings import AuthSettings +from mcp.server.mcpserver.server import MCPServer + +server = MCPServer( + "my-server", + token_verifier=JWTTokenVerifier(), + auth=AuthSettings( + issuer_url="https://auth.example.com", + resource_server_url="https://mcp.example.com", + required_scopes=["read"], + ), +) +``` + +Once the `AccessToken` reaches the middleware stack, the flow is automatic: `BearerAuthBackend` validates the token → `AuthContextMiddleware` extracts `tenant_id` → `tenant_id_var` contextvar is set → all downstream handlers and managers receive the correct tenant scope. + +### Session Isolation + +Sessions are automatically bound to their tenant on first authenticated request (set-once semantics). The `StreamableHTTPSessionManager` enforces this: + +- New sessions record the creating tenant's ID +- Subsequent requests must come from the same tenant +- Cross-tenant session access returns HTTP 404 +- Session tenant binding cannot be changed after initial assignment + +## Backward Compatibility + +All tenant-scoped APIs default to `tenant_id=None`, preserving single-tenant behavior: + +```python +# These all work exactly as before — no tenant scoping +server.add_tool(my_tool) +server.add_resource(my_resource) +await server.list_tools() # Returns tools in global (None) scope +``` + +Tools registered without a `tenant_id` live in the global scope and are only visible when no tenant context is active (i.e., `tenant_id_var` is not set or is `None`). + +## Architecture Notes + +### Storage Model + +Managers use a nested dictionary `{tenant_id: {name: item}}` for O(1) lookups per tenant. When the last item in a tenant scope is removed, the scope dictionary is cleaned up automatically. + +### Set-Once Session Binding + +`ServerSession.tenant_id` uses set-once semantics: once a session is bound to a tenant (on the first request with a non-None tenant_id), it cannot be changed. This prevents session fixation attacks where a session created by one tenant could be reused by another. + +### Context Merging + +The MCP protocol supports propagating the sender's `contextvars.Context` to +server-side handlers, enabling libraries like OpenTelemetry to correlate traces +across the client-server boundary. Without special handling, this would shadow +`tenant_id_var` because it is set on the server side (by `AuthContextMiddleware` +or the server task), not the client side. + +To preserve both client-side variables (e.g., OTel spans) and server-side +variables (e.g., `tenant_id_var`), the server merges the two contexts before +dispatching each request handler. `merge_contexts(sender, server)` creates a +new context using the sender's context as the base and overlaying all +server-side context variables on top. Handlers run inside this merged context. + +**Server wins on conflict.** If both contexts set the same `ContextVar`, the +server's value takes precedence. This is a deliberate security decision: clients +must not be able to control `tenant_id_var` by injecting it into their own +context. + +Notification handlers are unaffected — notifications do not carry sender +context and continue to run in a plain `copy_context()` from the server task. + +### Security Considerations + +- **Cross-tenant tool invocation**: A tenant can only call tools registered under their own tenant_id. Attempting to call a tool from another tenant's scope raises a `ToolError`. +- **Resource access**: Resources are tenant-scoped. Reading a resource registered under a different tenant raises a `ResourceError`. +- **Session hijacking**: The session manager validates the requesting tenant against the session's bound tenant on every request. Mismatches return HTTP 404 with an opaque "Session not found" error (no tenant information is leaked). +- **Log levels**: Tenant mismatch events are logged at WARNING level (session ID only). Sensitive tenant identifiers are logged at DEBUG level only. +- **`ctx.meta` is client-controlled**: Handlers must not use `ctx.meta` (the `_meta` field from JSON-RPC request params) for security decisions. This field is controlled by the client. Always rely on `ctx.tenant_id`, which is derived from the authenticated access token via server-side middleware. +- **Context merging and spoofing**: When client and server contexts both set the same `ContextVar`, the server's value takes precedence. This prevents a malicious client from spoofing `tenant_id_var` by injecting it into the sender context. 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 5fac56be5..6ef2f0b11 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 @@ -18,7 +18,7 @@ from urllib.parse import parse_qs, urlparse import httpx -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream +from mcp.client._transport import ReadStream, WriteStream from mcp.client.auth import OAuthClientProvider, TokenStorage from mcp.client.session import ClientSession from mcp.client.sse import sse_client @@ -241,8 +241,8 @@ async def _default_redirect_handler(authorization_url: str) -> None: async def _run_session( self, - read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], - write_stream: MemoryObjectSendStream[SessionMessage], + read_stream: ReadStream[SessionMessage | Exception], + write_stream: WriteStream[SessionMessage], ): """Run the MCP session with the given streams.""" print("🤝 Initializing MCP session...") diff --git a/examples/servers/simple-multi-tenant/README.md b/examples/servers/simple-multi-tenant/README.md new file mode 100644 index 000000000..096669443 --- /dev/null +++ b/examples/servers/simple-multi-tenant/README.md @@ -0,0 +1,194 @@ +# Multi-Tenant MCP Server Example + +Demonstrates tenant-scoped tools, resources, and prompts using the MCP Python SDK's multi-tenancy support. + +## What it shows + +- **Acme** (analytics company) has `run_query` and `generate_report` tools, a `database-schema` resource, and an `analyst` prompt +- **Globex** (content company) has `publish_article` and `check_seo` tools, a `style-guide` resource, and an `editor` prompt +- Each tenant sees only their own tools, resources, and prompts — Acme cannot see Globex's tools and vice versa +- A `whoami` tool is registered under both tenants and reports the current tenant identity from `Context.tenant_id` + +## Running + +Start the server without auth (no tenant context on HTTP — useful for in-memory testing): + +```bash +uv run mcp-simple-multi-tenant --port 3000 +``` + +Start with auth enabled (bearer tokens map to tenants — required for curl/HTTP testing): + +```bash +uv run mcp-simple-multi-tenant --port 3000 --auth +``` + +The server starts a StreamableHTTP endpoint at `http://127.0.0.1:3000/mcp`. + +## What each tenant sees + +**Acme** (analytics): + +- Tools: `run_query`, `generate_report`, `whoami` +- Resources: `data://schema` (database schema) +- Prompts: `analyst` (data analyst system prompt) + +**Globex** (content): + +- Tools: `publish_article`, `check_seo`, `whoami` +- Resources: `content://style-guide` (editorial style guide) +- Prompts: `editor` (content editor system prompt) + +**No tenant** (unauthenticated): sees nothing — all items are tenant-scoped. + +## Example: programmatic client + +You can verify tenant isolation using the MCP client with in-memory transport: + +```python +import asyncio + +from mcp.client.session import ClientSession +from mcp.shared._context import tenant_id_var +from mcp.shared.memory import create_client_server_memory_streams + +from mcp_simple_multi_tenant.server import create_server + + +async def main(): + server = create_server() + # NOTE: _lowlevel_server is a private API used here for in-memory testing only. + # In production, use HTTP transport with server.run() and a TokenVerifier instead. + actual = server._lowlevel_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 + + import anyio + + async with anyio.create_task_group() as tg: + # Set tenant context for the server side + async def run_server(): + token = tenant_id_var.set("acme") + try: + await actual.run( + server_read, + server_write, + actual.create_initialization_options(), + ) + finally: + tenant_id_var.reset(token) + + tg.start_soon(run_server) + + async with ClientSession(client_read, client_write) as session: + await session.initialize() + + # Acme sees only analytics tools + tools = await session.list_tools() + print(f"Tools: {[t.name for t in tools.tools]}") + # → ['run_query', 'generate_report', 'whoami'] + + result = await session.call_tool( + "run_query", {"sql": "SELECT * FROM users"} + ) + print(f"Result: {result.content[0].text}") + # → Query result for: SELECT * FROM users (3 rows returned) + + tg.cancel_scope.cancel() + + +asyncio.run(main()) +``` + +## Testing with curl + +Start the server with `--auth` to enable bearer token authentication: + +```bash +uv run mcp-simple-multi-tenant --port 3000 --auth +``` + +Demo bearer tokens: + +| Token | Tenant | +|---|---| +| `acme-token` | acme | +| `globex-token` | globex | + +### Step 1: Initialize a session as Acme + +```bash +curl -s -D- -X POST http://127.0.0.1:3000/mcp \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "Authorization: Bearer acme-token" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "curl-test", "version": "0.1"} + } + }' +``` + +Look for the `mcp-session-id` header in the response — you'll need it for subsequent requests. + +### Step 2: Send initialized notification + +```bash +curl -s -X POST http://127.0.0.1:3000/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer acme-token" \ + -H "Mcp-Session-Id: " \ + -d '{"jsonrpc": "2.0", "method": "notifications/initialized"}' +``` + +### Step 3: List tools (Acme sees only its tools) + +```bash +curl -s -X POST http://127.0.0.1:3000/mcp \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "Authorization: Bearer acme-token" \ + -H "Mcp-Session-Id: " \ + -d '{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}' +``` + +Response will include `run_query`, `generate_report`, and `whoami` — but NOT Globex's tools. + +### Step 4: Call a tool + +```bash +curl -s -X POST http://127.0.0.1:3000/mcp \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "Authorization: Bearer acme-token" \ + -H "Mcp-Session-Id: " \ + -d '{ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": {"name": "whoami", "arguments": {}} + }' +``` + +### Unauthenticated requests are rejected + +```bash +curl -s -D- -X POST http://127.0.0.1:3000/mcp \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "test", "version": "0.1"}}}' +# → HTTP 401 Unauthorized +``` + +## How tenant identity works + +In a production deployment, `tenant_id` is extracted from the OAuth `AccessToken` by the `AuthContextMiddleware` and propagated through the request context automatically — no manual `tenant_id_var.set()` is needed. The in-memory example above sets it manually to simulate what the middleware does. + +See the [Multi-Tenancy Guide](../../../docs/multi-tenancy.md) for the full architecture. diff --git a/examples/servers/simple-multi-tenant/mcp_simple_multi_tenant/__init__.py b/examples/servers/simple-multi-tenant/mcp_simple_multi_tenant/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/servers/simple-multi-tenant/mcp_simple_multi_tenant/__main__.py b/examples/servers/simple-multi-tenant/mcp_simple_multi_tenant/__main__.py new file mode 100644 index 000000000..e7ef16530 --- /dev/null +++ b/examples/servers/simple-multi-tenant/mcp_simple_multi_tenant/__main__.py @@ -0,0 +1,5 @@ +import sys + +from .server import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-multi-tenant/mcp_simple_multi_tenant/server.py b/examples/servers/simple-multi-tenant/mcp_simple_multi_tenant/server.py new file mode 100644 index 000000000..956a605b1 --- /dev/null +++ b/examples/servers/simple-multi-tenant/mcp_simple_multi_tenant/server.py @@ -0,0 +1,190 @@ +"""Multi-tenant MCP server example. + +Demonstrates how to register tenant-scoped tools, resources, and prompts +so that each tenant sees only their own items. Tenant identity is +determined by the ``tenant_id`` field on the OAuth ``AccessToken`` and +propagated automatically through the request context. + +In this example, "acme" is an analytics company with data tools, while +"globex" is a content company with publishing tools. Each tenant has +completely different capabilities — they share nothing. + +Run with ``--auth`` to enable bearer token authentication with +hard-coded demo tokens (see ``DEMO_TOKENS`` below). Without ``--auth``, +the server runs without authentication and HTTP clients will see no +tenant-scoped items. +""" + +import logging +import time + +import click +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.server.auth.settings import AuthSettings +from mcp.server.mcpserver.context import Context +from mcp.server.mcpserver.prompts.base import Prompt +from mcp.server.mcpserver.resources.types import FunctionResource +from mcp.server.mcpserver.server import MCPServer +from pydantic import AnyHttpUrl + +logger = logging.getLogger(__name__) + +# Hard-coded demo tokens for testing with --auth. +# In production, replace StubTokenVerifier with a real JWT/OAuth verifier. +DEMO_TOKENS: dict[str, str] = { + "acme-token": "acme", + "globex-token": "globex", +} + + +class StubTokenVerifier(TokenVerifier): + """Token verifier that maps hard-coded bearer tokens to tenants. + + This is for demonstration only. In production, use a real OAuth + introspection endpoint or JWT decoder. + """ + + def __init__(self, token_map: dict[str, str]) -> None: + now = int(time.time()) + self._tokens: dict[str, AccessToken] = { + token: AccessToken( + token=token, + client_id=f"client-{tenant_id}", + scopes=["read"], + expires_at=now + 86400, + tenant_id=tenant_id, + ) + for token, tenant_id in token_map.items() + } + + async def verify_token(self, token: str) -> AccessToken | None: + return self._tokens.get(token) + + +def create_server(*, auth: bool = False) -> MCPServer: + """Create an MCPServer with tenant-scoped tools, resources, and prompts. + + Each tenant has completely different tools, resources, and prompts. + Acme is an analytics company; Globex is a content company. + + Args: + auth: If True, enable bearer token authentication using + StubTokenVerifier with the hard-coded DEMO_TOKENS. + """ + + verifier = StubTokenVerifier(DEMO_TOKENS) if auth else None + auth_settings = ( + AuthSettings( + issuer_url=AnyHttpUrl("https://auth.example.com"), + resource_server_url=AnyHttpUrl("https://mcp.example.com"), + required_scopes=["read"], + ) + if auth + else None + ) + + server = MCPServer( + "multi-tenant-demo", + token_verifier=verifier, + auth=auth_settings, + ) + + # -- Tenant "acme" (analytics company) --------------------------------- + + def run_query(sql: str) -> str: + """Execute an analytics query.""" + return f"Query result for: {sql} (3 rows returned)" + + def generate_report(metric: str, period: str) -> str: + """Generate an analytics report.""" + return f"Report: {metric} over {period} — trend is up 12%" + + server.add_tool(run_query, tenant_id="acme") + server.add_tool(generate_report, tenant_id="acme") + + server.add_resource( + FunctionResource( + uri="data://schema", + name="database-schema", + fn=lambda: "tables: users, events, metrics", + ), + tenant_id="acme", + ) + + async def acme_analyst_prompt() -> str: + return "You are a data analyst. Help the user write SQL queries and interpret results." + + server.add_prompt(Prompt.from_function(acme_analyst_prompt, name="analyst"), tenant_id="acme") + + # -- Tenant "globex" (content company) --------------------------------- + + def publish_article(title: str, body: str) -> str: + """Publish an article to the CMS.""" + return f"Published: '{title}' ({len(body)} chars)" + + def check_seo(url: str) -> str: + """Check SEO score for a URL.""" + return f"SEO score for {url}: 87/100 — missing meta description" + + server.add_tool(publish_article, tenant_id="globex") + server.add_tool(check_seo, tenant_id="globex") + + server.add_resource( + FunctionResource( + uri="content://style-guide", + name="style-guide", + fn=lambda: "Tone: professional but approachable. Max paragraph length: 3 sentences.", + ), + tenant_id="globex", + ) + + async def globex_editor_prompt() -> str: + return "You are a content editor. Help the user write and publish articles." + + server.add_prompt(Prompt.from_function(globex_editor_prompt, name="editor"), tenant_id="globex") + + # -- Shared "whoami" tool (registered per tenant) ---------------------- + # There is no global scope fallback — tools must be registered under + # each tenant that needs them. + + def whoami(ctx: Context) -> str: + """Return the current tenant identity.""" + return f"tenant: {ctx.tenant_id or 'anonymous'}" + + server.add_tool(whoami, name="whoami", tenant_id="acme") + server.add_tool(whoami, name="whoami", tenant_id="globex") + + return server + + +@click.command() +@click.option("--port", default=3000, help="Port to listen on") +@click.option("--auth", is_flag=True, help="Enable bearer token authentication") +@click.option( + "--log-level", + default="INFO", + help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)", +) +def main(port: int, auth: bool, log_level: str) -> int: + """Run the multi-tenant MCP demo server. + + Acme (analytics) and Globex (content) each have completely different + tools, resources, and prompts. Neither tenant can see the other's items. + """ + logging.basicConfig( + level=getattr(logging, log_level.upper()), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + server = create_server(auth=auth) + if auth: + logger.info("Auth enabled. Demo bearer tokens:") + for token, tenant_id in DEMO_TOKENS.items(): + logger.info(f" Bearer {token} -> tenant '{tenant_id}'") + logger.info(f"Starting multi-tenant MCP server on port {port}") + server.run(transport="streamable-http", host="127.0.0.1", port=port) + return 0 + + +if __name__ == "__main__": + main() # type: ignore[call-arg] diff --git a/examples/servers/simple-multi-tenant/pyproject.toml b/examples/servers/simple-multi-tenant/pyproject.toml new file mode 100644 index 000000000..62db20b2e --- /dev/null +++ b/examples/servers/simple-multi-tenant/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "mcp-simple-multi-tenant" +version = "0.1.0" +description = "A simple MCP server demonstrating multi-tenant isolation" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "multi-tenant"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["click>=8.2.0", "mcp"] + +[project.scripts] +mcp-simple-multi-tenant = "mcp_simple_multi_tenant.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_multi_tenant"] + +[tool.pyright] +include = ["mcp_simple_multi_tenant"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/pyproject.toml b/pyproject.toml index e1b19e3c9..a5d2c3d80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dependencies = [ "pyjwt[crypto]>=2.10.1", "typing-extensions>=4.13.0", "typing-inspection>=0.4.1", + "opentelemetry-api>=1.28.0", ] [project.optional-dependencies] @@ -71,6 +72,7 @@ dev = [ "coverage[toml]>=7.10.7,<=7.13", "pillow>=12.0", "strict-no-cover", + "logfire>=3.0.0", ] docs = [ "mkdocs>=1.6.1", @@ -183,7 +185,6 @@ filterwarnings = [ # This should be fixed on Uvicorn's side. "ignore::DeprecationWarning:websockets", "ignore:websockets.server.WebSocketServerProtocol is deprecated:DeprecationWarning", - "ignore:Returning str or bytes.*:DeprecationWarning:mcp.server.lowlevel", # pywin32 internal deprecation warning "ignore:getargs.*The 'u' format is deprecated:DeprecationWarning", ] @@ -219,13 +220,10 @@ skip_covered = true show_missing = true ignore_errors = true precision = 2 -exclude_lines = [ - "pragma: no cover", +exclude_also = [ "pragma: lax no cover", - "if TYPE_CHECKING:", "@overload", "raise NotImplementedError", - "^\\s*\\.\\.\\.\\s*$", ] # https://coverage.readthedocs.io/en/latest/config.html#paths diff --git a/src/mcp/client/__main__.py b/src/mcp/client/__main__.py index f3db17906..b9ec34422 100644 --- a/src/mcp/client/__main__.py +++ b/src/mcp/client/__main__.py @@ -6,9 +6,9 @@ from urllib.parse import urlparse import anyio -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from mcp import types +from mcp.client._transport import ReadStream, WriteStream from mcp.client.session import ClientSession from mcp.client.sse import sse_client from mcp.client.stdio import StdioServerParameters, stdio_client @@ -33,8 +33,8 @@ async def message_handler( async def run_session( - read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], - write_stream: MemoryObjectSendStream[SessionMessage], + read_stream: ReadStream[SessionMessage | Exception], + write_stream: WriteStream[SessionMessage], client_info: types.Implementation | None = None, ): async with ClientSession( diff --git a/src/mcp/client/_transport.py b/src/mcp/client/_transport.py index a86362900..0163fef95 100644 --- a/src/mcp/client/_transport.py +++ b/src/mcp/client/_transport.py @@ -5,11 +5,12 @@ from contextlib import AbstractAsyncContextManager from typing import Protocol -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream - +from mcp.shared._stream_protocols import ReadStream, WriteStream from mcp.shared.message import SessionMessage -TransportStreams = tuple[MemoryObjectReceiveStream[SessionMessage | Exception], MemoryObjectSendStream[SessionMessage]] +__all__ = ["ReadStream", "WriteStream", "Transport", "TransportStreams"] + +TransportStreams = tuple[ReadStream[SessionMessage | Exception], WriteStream[SessionMessage]] class Transport(AbstractAsyncContextManager[TransportStreams], Protocol): diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index 25075dec3..72309f577 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -320,7 +320,7 @@ async def _perform_authorization_code_grant(self) -> tuple[str, str]: raise OAuthFlowError("No callback handler provided for authorization code grant") # pragma: no cover if self.context.oauth_metadata and self.context.oauth_metadata.authorization_endpoint: - auth_endpoint = str(self.context.oauth_metadata.authorization_endpoint) # pragma: no cover + auth_endpoint = str(self.context.oauth_metadata.authorization_endpoint) else: auth_base_url = self.context.get_authorization_base_url(self.context.server_url) auth_endpoint = urljoin(auth_base_url, "/authorize") @@ -343,11 +343,16 @@ async def _perform_authorization_code_grant(self) -> tuple[str, str]: # Only include resource param if conditions are met if self.context.should_include_resource_param(self.context.protocol_version): - auth_params["resource"] = self.context.get_resource_url() # RFC 8707 # pragma: no cover + auth_params["resource"] = self.context.get_resource_url() # RFC 8707 if self.context.client_metadata.scope: # pragma: no branch auth_params["scope"] = self.context.client_metadata.scope + # OIDC requires prompt=consent when offline_access is requested + # https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess + if "offline_access" in self.context.client_metadata.scope.split(): + auth_params["prompt"] = "consent" + authorization_url = f"{auth_endpoint}?{urlencode(auth_params)}" await self.context.redirect_handler(authorization_url) @@ -576,6 +581,7 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. extract_scope_from_www_auth(response), self.context.protected_resource_metadata, self.context.oauth_metadata, + self.context.client_metadata.grant_types, ) # Step 4: Register client or use URL-based client ID (CIMD) @@ -622,7 +628,10 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. try: # Step 2a: Update the required scopes self.context.client_metadata.scope = get_client_metadata_scopes( - extract_scope_from_www_auth(response), self.context.protected_resource_metadata + extract_scope_from_www_auth(response), + self.context.protected_resource_metadata, + self.context.oauth_metadata, + self.context.client_metadata.grant_types, ) # Step 2b: Perform (re-)authorization and token exchange diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index 0ca36b98d..d75324f2f 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -99,24 +99,36 @@ def get_client_metadata_scopes( www_authenticate_scope: str | None, protected_resource_metadata: ProtectedResourceMetadata | None, authorization_server_metadata: OAuthMetadata | None = None, + client_grant_types: list[str] | None = None, ) -> str | None: - """Select scopes as outlined in the 'Scope Selection Strategy' in the MCP spec.""" - # Per MCP spec, scope selection priority order: - # 1. Use scope from WWW-Authenticate header (if provided) - # 2. Use all scopes from PRM scopes_supported (if available) - # 3. Omit scope parameter if neither is available - + """Select effective scopes and augment for refresh token support.""" + selected_scope: str | None = None + + # MCP spec scope selection priority: + # 1. WWW-Authenticate header scope + # 2. PRM scopes_supported + # 3. AS scopes_supported (SDK fallback) + # 4. Omit scope parameter if www_authenticate_scope is not None: - # Priority 1: WWW-Authenticate header scope - return www_authenticate_scope + selected_scope = www_authenticate_scope elif protected_resource_metadata is not None and protected_resource_metadata.scopes_supported is not None: - # Priority 2: PRM scopes_supported - return " ".join(protected_resource_metadata.scopes_supported) + selected_scope = " ".join(protected_resource_metadata.scopes_supported) elif authorization_server_metadata is not None and authorization_server_metadata.scopes_supported is not None: - return " ".join(authorization_server_metadata.scopes_supported) # pragma: no cover - else: - # Priority 3: Omit scope parameter - return None + selected_scope = " ".join(authorization_server_metadata.scopes_supported) + + # SEP-2207: append offline_access when the AS supports it and the client can use refresh tokens + if ( + selected_scope is not None + and authorization_server_metadata is not None + and authorization_server_metadata.scopes_supported is not None + and "offline_access" in authorization_server_metadata.scopes_supported + and client_grant_types is not None + and "refresh_token" in client_grant_types + and "offline_access" not in selected_scope.split() + ): + selected_scope = f"{selected_scope} offline_access" + + return selected_scope def build_oauth_authorization_server_metadata_discovery_urls(auth_server_url: str | None, server_url: str) -> list[str]: diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 7c964a334..8b895eaf5 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -4,10 +4,10 @@ from typing import Any, Protocol import anyio.lowlevel -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from pydantic import TypeAdapter from mcp import types +from mcp.client._transport import ReadStream, WriteStream from mcp.client.experimental import ExperimentalClientFeatures from mcp.client.experimental.task_handlers import ExperimentalTaskHandlers from mcp.shared._context import RequestContext @@ -109,8 +109,8 @@ class ClientSession( ): def __init__( self, - read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], - write_stream: MemoryObjectSendStream[SessionMessage], + read_stream: ReadStream[SessionMessage | Exception], + write_stream: WriteStream[SessionMessage], read_timeout_seconds: float | None = None, sampling_callback: SamplingFnT | None = None, elicitation_callback: ElicitationFnT | None = None, @@ -408,7 +408,7 @@ async def list_tools(self, *, params: types.PaginatedRequestParams | None = None return result - async def send_roots_list_changed(self) -> None: # pragma: no cover + async def send_roots_list_changed(self) -> None: """Send a roots/list_changed notification.""" await self.send_notification(types.RootsListChangedNotification()) diff --git a/src/mcp/client/sse.py b/src/mcp/client/sse.py index 7b66b5c1b..193204a15 100644 --- a/src/mcp/client/sse.py +++ b/src/mcp/client/sse.py @@ -7,11 +7,11 @@ import anyio import httpx from anyio.abc import TaskStatus -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from httpx_sse import aconnect_sse from httpx_sse._exceptions import SSEError from mcp import types +from mcp.shared._context_streams import create_context_streams from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client from mcp.shared.message import SessionMessage @@ -51,12 +51,6 @@ async def sse_client( auth: Optional HTTPX authentication handler. on_session_created: Optional callback invoked with the session ID when received. """ - read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] - read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] - - write_stream: MemoryObjectSendStream[SessionMessage] - write_stream_reader: MemoryObjectReceiveStream[SessionMessage] - logger.debug(f"Connecting to SSE endpoint: {remove_request_params(url)}") async with httpx_client_factory( headers=headers, auth=auth, timeout=httpx.Timeout(timeout, read=sse_read_timeout) @@ -65,8 +59,8 @@ async def sse_client( event_source.response.raise_for_status() logger.debug("SSE connection established") - read_stream_writer, read_stream = anyio.create_memory_object_stream(0) - write_stream, write_stream_reader = anyio.create_memory_object_stream(0) + read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0) + write_stream, write_stream_reader = create_context_streams[SessionMessage](0) async def sse_reader(task_status: TaskStatus[str] = anyio.TASK_STATUS_IGNORED): try: @@ -124,7 +118,8 @@ async def sse_reader(task_status: TaskStatus[str] = anyio.TASK_STATUS_IGNORED): async def post_writer(endpoint_url: str): try: async with write_stream_reader, write_stream: - async for session_message in write_stream_reader: + + async def _send_message(session_message: SessionMessage) -> None: logger.debug(f"Sending client message: {session_message}") response = await client.post( endpoint_url, @@ -136,6 +131,14 @@ async def post_writer(endpoint_url: str): ) response.raise_for_status() logger.debug(f"Client message sent successfully: {response.status_code}") + + async for session_message in write_stream_reader: + sender_ctx = write_stream_reader.last_context + if sender_ctx is not None: + async with anyio.create_task_group() as tg: + sender_ctx.run(tg.start_soon, _send_message, session_message) + else: + await _send_message(session_message) # pragma: no cover except Exception: # pragma: lax no cover logger.exception("Error in post_writer") diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index 3afb94b03..9a119c633 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -11,11 +11,11 @@ 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.client._transport import TransportStreams +from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams from mcp.shared._httpx_utils import create_mcp_http_client from mcp.shared.message import ClientMessageMetadata, SessionMessage from mcp.types import ( @@ -38,8 +38,8 @@ # TODO(Marcelo): Put the TransportStreams in a module under shared, so we can import here. SessionMessageOrError = SessionMessage | Exception -StreamWriter = MemoryObjectSendStream[SessionMessageOrError] -StreamReader = MemoryObjectReceiveStream[SessionMessage] +StreamWriter = ContextSendStream[SessionMessageOrError] +StreamReader = ContextReceiveStream[SessionMessage] MCP_SESSION_ID = "mcp-session-id" MCP_PROTOCOL_VERSION = "mcp-protocol-version" @@ -434,14 +434,15 @@ async def post_writer( client: httpx.AsyncClient, write_stream_reader: StreamReader, read_stream_writer: StreamWriter, - write_stream: MemoryObjectSendStream[SessionMessage], + write_stream: ContextSendStream[SessionMessage], start_get_stream: Callable[[], None], tg: TaskGroup, ) -> None: """Handle writing requests to the server.""" try: async with write_stream_reader, read_stream_writer, write_stream: - async for session_message in write_stream_reader: + + async def _handle_message(session_message: SessionMessage) -> None: message = session_message.message metadata = ( session_message.metadata @@ -478,6 +479,14 @@ async def handle_request_async(): else: await handle_request_async() + async for session_message in write_stream_reader: + sender_ctx = write_stream_reader.last_context + if sender_ctx is not None: + async with anyio.create_task_group() as tg_local: + sender_ctx.run(tg_local.start_soon, _handle_message, session_message) + else: + await _handle_message(session_message) # pragma: no cover + except Exception: # pragma: lax no cover logger.exception("Error in post_writer") @@ -547,8 +556,8 @@ async def streamable_http_client( if not client_provided: await stack.enter_async_context(client) - 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) + read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0) + write_stream, write_stream_reader = create_context_streams[SessionMessage](0) async with ( read_stream_writer, diff --git a/src/mcp/server/auth/middleware/auth_context.py b/src/mcp/server/auth/middleware/auth_context.py index 1d34a5546..2cee836e1 100644 --- a/src/mcp/server/auth/middleware/auth_context.py +++ b/src/mcp/server/auth/middleware/auth_context.py @@ -4,6 +4,7 @@ from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser from mcp.server.auth.provider import AccessToken +from mcp.shared._context import tenant_id_var # Create a contextvar to store the authenticated user # The default is None, indicating no authenticated user is present @@ -20,6 +21,16 @@ def get_access_token() -> AccessToken | None: return auth_user.access_token if auth_user else None +def get_tenant_id() -> str | None: + """Get the tenant_id from the current authentication context. + + Returns: + The tenant_id if an authenticated user with a tenant is available, None otherwise. + """ + access_token = get_access_token() + return access_token.tenant_id if access_token else None + + class AuthContextMiddleware: """Middleware that extracts the authenticated user from the request and sets it in a contextvar for easy access throughout the request lifecycle. @@ -36,11 +47,15 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send): user = scope.get("user") if isinstance(user, AuthenticatedUser): # Set the authenticated user in the contextvar - token = auth_context_var.set(user) + auth_token = auth_context_var.set(user) + # Propagate tenant_id to the transport-agnostic contextvar + tenant_id = user.access_token.tenant_id if user.access_token else None + tenant_token = tenant_id_var.set(tenant_id) try: await self.app(scope, receive, send) finally: - auth_context_var.reset(token) + tenant_id_var.reset(tenant_token) + auth_context_var.reset(auth_token) else: # No authenticated user, just process the request await self.app(scope, receive, send) diff --git a/src/mcp/server/auth/middleware/bearer_auth.py b/src/mcp/server/auth/middleware/bearer_auth.py index 6825c00b9..2eafdc793 100644 --- a/src/mcp/server/auth/middleware/bearer_auth.py +++ b/src/mcp/server/auth/middleware/bearer_auth.py @@ -95,7 +95,7 @@ async def _send_auth_error(self, send: Send, status_code: int, error: str, descr """Send an authentication error response with WWW-Authenticate header.""" # Build WWW-Authenticate header value www_auth_parts = [f'error="{error}"', f'error_description="{description}"'] - if self.resource_metadata_url: # pragma: no cover + if self.resource_metadata_url: www_auth_parts.append(f'resource_metadata="{self.resource_metadata_url}"') www_authenticate = f"Bearer {', '.join(www_auth_parts)}" diff --git a/src/mcp/server/auth/provider.py b/src/mcp/server/auth/provider.py index 957082a85..2b0a4ad53 100644 --- a/src/mcp/server/auth/provider.py +++ b/src/mcp/server/auth/provider.py @@ -25,6 +25,7 @@ class AuthorizationCode(BaseModel): redirect_uri: AnyUrl redirect_uri_provided_explicitly: bool resource: str | None = None # RFC 8707 resource indicator + tenant_id: str | None = None # Tenant this code belongs to class RefreshToken(BaseModel): @@ -32,6 +33,7 @@ class RefreshToken(BaseModel): client_id: str scopes: list[str] expires_at: int | None = None + tenant_id: str | None = None # Tenant this token belongs to class AccessToken(BaseModel): @@ -40,6 +42,7 @@ class AccessToken(BaseModel): scopes: list[str] expires_at: int | None = None resource: str | None = None # RFC 8707 resource indicator + tenant_id: str | None = None # Tenant this token belongs to RegistrationErrorCode = Literal[ diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index c28842272..8cef1aadd 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -36,15 +36,16 @@ async def main(): from __future__ import annotations +import contextvars import logging import warnings from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager from importlib.metadata import version as importlib_version -from typing import Any, Generic +from typing import Any, Generic, cast import anyio -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream +from opentelemetry.trace import SpanKind, StatusCode from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.middleware.authentication import AuthenticationMiddleware @@ -65,6 +66,9 @@ async def main(): 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._context import merge_contexts, tenant_id_var +from mcp.shared._otel import extract_trace_context, otel_span +from mcp.shared._stream_protocols import ReadStream, WriteStream from mcp.shared.exceptions import MCPError from mcp.shared.message import ServerMessageMetadata, SessionMessage from mcp.shared.session import RequestResponder @@ -355,8 +359,8 @@ def session_manager(self) -> StreamableHTTPSessionManager: async def run( self, - read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], - write_stream: MemoryObjectSendStream[SessionMessage], + read_stream: ReadStream[SessionMessage | Exception], + write_stream: WriteStream[SessionMessage], initialization_options: InitializationOptions, # When False, exceptions are returned as messages to the client. # When True, exceptions are raised, which will cause the server to shut down @@ -391,7 +395,14 @@ async def run( async for message in session.incoming_messages: logger.debug("Received message: %s", message) - tg.start_soon( + server_context = contextvars.copy_context() + if isinstance(message, RequestResponder) and message.context is not None: + context = merge_contexts(message.context, server_context) + else: + context = server_context + + context.run( + tg.start_soon, self._handle_message, message, session, @@ -439,72 +450,94 @@ async def _handle_request( ): logger.info("Processing request of type %s", type(req).__name__) - if handler := self._request_handlers.get(req.method): - logger.debug("Dispatching request of type %s", type(req).__name__) + target = getattr(req.params, "name", None) if req.params else None + span_name = f"MCP handle {req.method} {target}" if target else f"MCP handle {req.method}" - 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): - 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 + # Extract W3C trace context from _meta (SEP-414). + meta = cast(dict[str, Any] | None, getattr(req.params, "meta", None)) if req.params else None + parent_context = extract_trace_context(meta) if meta is not None else None - 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) - 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(ctx, req.params) - except MCPError as err: - response = err.error - except anyio.get_cancelled_exc_class(): - if message.cancelled: - # Client sent CancelledNotification; responder.cancel() already - # sent an error response, so skip the duplicate. - logger.info("Request %s cancelled - duplicate response suppressed", message.request_id) - return - # Transport-close cancellation from the TG in run(); re-raise so the - # TG swallows its own cancellation. - raise - except Exception as err: - if raise_exceptions: # pragma: no cover - raise err - response = types.ErrorData(code=0, message=str(err)) - else: # pragma: no cover - response = types.ErrorData(code=types.METHOD_NOT_FOUND, message="Method not found") - - try: - await message.respond(response) - except (anyio.BrokenResourceError, anyio.ClosedResourceError): - # Transport closed between handler unblocking and respond. Happens - # when _receive_loop's finally wakes a handler blocked on - # send_request: the handler runs to respond() before run()'s TG - # cancel fires, but after the write stream closed. Closed if our - # end closed (_receive_loop's async-with exit); Broken if the peer - # end closed first (streamable_http terminate()). - logger.debug("Response for %s dropped - transport closed", message.request_id) - return - - logger.debug("Response sent") + with otel_span( + span_name, + kind=SpanKind.SERVER, + attributes={"mcp.method.name": req.method, "jsonrpc.request.id": message.request_id}, + context=parent_context, + ) as span: + if handler := self._request_handlers.get(req.method): + logger.debug("Dispatching request of type %s", type(req).__name__) + + 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 + ): + 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 + + 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: # pragma: no branch + task_metadata = getattr(req.params, "task", None) + tenant_id = tenant_id_var.get() + if tenant_id is not None and session.tenant_id is None: + session.tenant_id = tenant_id + ctx = ServerRequestContext( + request_id=message.request_id, + meta=message.request_meta, + session=session, + lifespan_context=lifespan_context, + tenant_id=tenant_id, + 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(ctx, req.params) + except MCPError as err: + response = err.error + except anyio.get_cancelled_exc_class(): + if message.cancelled: + # Client sent CancelledNotification; responder.cancel() already + # sent an error response, so skip the duplicate. + logger.info("Request %s cancelled - duplicate response suppressed", message.request_id) + return + # Transport-close cancellation from the TG in run(); re-raise so the + # TG swallows its own cancellation. + raise + except Exception as err: + if raise_exceptions: # pragma: no cover + raise err + response = types.ErrorData(code=0, message=str(err)) + else: # pragma: no cover + response = types.ErrorData(code=types.METHOD_NOT_FOUND, message="Method not found") + + if isinstance(response, types.ErrorData) and span is not None: + span.set_status(StatusCode.ERROR, response.message) + + try: + await message.respond(response) + except (anyio.BrokenResourceError, anyio.ClosedResourceError): + # Transport closed between handler unblocking and respond. Happens + # when _receive_loop's finally wakes a handler blocked on + # send_request: the handler runs to respond() before run()'s TG + # cancel fires, but after the write stream closed. Closed if our + # end closed (_receive_loop's async-with exit); Broken if the peer + # end closed first (streamable_http terminate()). + logger.debug("Response for %s dropped - transport closed", message.request_id) + return + + logger.debug("Response sent") async def _handle_notification( self, @@ -518,9 +551,13 @@ async def _handle_notification( try: 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 + tenant_id = tenant_id_var.get() + if tenant_id is not None and session.tenant_id is None: + session.tenant_id = tenant_id ctx = ServerRequestContext( session=session, lifespan_context=lifespan_context, + tenant_id=tenant_id, experimental=Experimental( task_metadata=None, _client_capabilities=client_capabilities, @@ -576,7 +613,7 @@ def streamable_http_app( required_scopes: list[str] = [] # Set up auth if configured - if auth: # pragma: no cover + if auth: required_scopes = auth.required_scopes or [] # Add auth middleware if token verifier is available @@ -590,7 +627,7 @@ def streamable_http_app( ] # Add auth endpoints if auth server provider is configured - if auth_server_provider: + if auth_server_provider: # pragma: no cover routes.extend( create_auth_routes( provider=auth_server_provider, @@ -602,10 +639,10 @@ def streamable_http_app( ) # Set up routes with or without auth - if token_verifier: # pragma: no cover + if token_verifier: # Determine resource metadata URL resource_metadata_url = None - if auth and auth.resource_server_url: + if auth and auth.resource_server_url: # pragma: no branch # Build compliant metadata URL for WWW-Authenticate header resource_metadata_url = build_resource_metadata_url(auth.resource_server_url) @@ -625,7 +662,7 @@ def streamable_http_app( ) # Add protected resource metadata endpoint if configured as RS - if auth and auth.resource_server_url: # pragma: no cover + if auth and auth.resource_server_url: routes.extend( create_protected_resource_routes( resource_url=auth.resource_server_url, diff --git a/src/mcp/server/mcpserver/context.py b/src/mcp/server/mcpserver/context.py index 1538adc7c..ed1d49e8a 100644 --- a/src/mcp/server/mcpserver/context.py +++ b/src/mcp/server/mcpserver/context.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Iterable -from typing import TYPE_CHECKING, Any, Generic, Literal +from typing import TYPE_CHECKING, Any, Generic from pydantic import AnyUrl, BaseModel @@ -14,6 +14,7 @@ elicit_with_validation, ) from mcp.server.lowlevel.helper_types import ReadResourceContents +from mcp.types import LoggingLevel if TYPE_CHECKING: from mcp.server.mcpserver.server import MCPServer @@ -69,6 +70,13 @@ def __init__( self._request_context = request_context self._mcp_server = mcp_server + @property + def tenant_id(self) -> str | None: + """Get the tenant_id for this request, if available.""" + if self._request_context is not None: + return self._request_context.tenant_id + return None + @property def mcp_server(self) -> MCPServer: """Access to the MCPServer instance.""" @@ -114,7 +122,7 @@ async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContent The resource content as either text or bytes """ assert self._mcp_server is not None, "Context is not available outside of a request" - return await self._mcp_server.read_resource(uri, self) + return await self._mcp_server.read_resource(uri, self, tenant_id=self.tenant_id) async def elicit( self, @@ -186,29 +194,23 @@ async def elicit_url( async def log( self, - level: Literal["debug", "info", "warning", "error"], - message: str, + level: LoggingLevel, + data: Any, *, logger_name: str | None = None, - extra: dict[str, Any] | None = None, ) -> None: """Send a log message to the client. Args: - level: Log level (debug, info, warning, error) - message: Log message + level: Log level (debug, info, notice, warning, error, critical, + alert, emergency) + data: The data to be logged. Any JSON serializable type is allowed + (string, dict, list, number, bool, etc.) per the MCP specification. logger_name: Optional logger name - 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=log_data, + data=data, logger=logger_name, related_request_id=self.request_id, ) @@ -261,20 +263,18 @@ 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, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None: + async def debug(self, data: Any, *, logger_name: str | None = None) -> None: """Send a debug log message.""" - await self.log("debug", message, logger_name=logger_name, extra=extra) + await self.log("debug", data, logger_name=logger_name) - async def info(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None: + async def info(self, data: Any, *, logger_name: str | None = None) -> None: """Send an info log message.""" - await self.log("info", message, logger_name=logger_name, extra=extra) + await self.log("info", data, logger_name=logger_name) - async def warning( - self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None - ) -> None: + async def warning(self, data: Any, *, logger_name: str | None = None) -> None: """Send a warning log message.""" - await self.log("warning", message, logger_name=logger_name, extra=extra) + await self.log("warning", data, logger_name=logger_name) - async def error(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None: + async def error(self, data: Any, *, logger_name: str | None = None) -> None: """Send an error log message.""" - await self.log("error", message, logger_name=logger_name, extra=extra) + await self.log("error", data, logger_name=logger_name) diff --git a/src/mcp/server/mcpserver/prompts/base.py b/src/mcp/server/mcpserver/prompts/base.py index 0c319d53c..e5b2af7d8 100644 --- a/src/mcp/server/mcpserver/prompts/base.py +++ b/src/mcp/server/mcpserver/prompts/base.py @@ -2,15 +2,17 @@ from __future__ import annotations -import inspect +import functools from collections.abc import Awaitable, Callable, Sequence from typing import TYPE_CHECKING, Any, Literal +import anyio.to_thread import pydantic_core from pydantic import BaseModel, Field, TypeAdapter, validate_call 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.shared._callable_inspection import is_async_callable from mcp.types import ContentBlock, Icon, TextContent if TYPE_CHECKING: @@ -155,10 +157,11 @@ async def render( # Add context to arguments if needed call_args = inject_context(self.fn, arguments or {}, context, self.context_kwarg) - # Call function and check if result is a coroutine - result = self.fn(**call_args) - if inspect.iscoroutine(result): - result = await result + fn = self.fn + if is_async_callable(fn): + result = await fn(**call_args) + else: + result = await anyio.to_thread.run_sync(functools.partial(self.fn, **call_args)) # Validate messages if not isinstance(result, list | tuple): diff --git a/src/mcp/server/mcpserver/prompts/manager.py b/src/mcp/server/mcpserver/prompts/manager.py index 28a7a6e98..7c5144082 100644 --- a/src/mcp/server/mcpserver/prompts/manager.py +++ b/src/mcp/server/mcpserver/prompts/manager.py @@ -15,44 +15,68 @@ class PromptManager: - """Manages MCPServer prompts.""" + """Manages MCPServer prompts with optional tenant-scoped storage. + + Prompts are stored in a nested dict: ``{tenant_id: {prompt_name: Prompt}}``. + This allows the same prompt name to exist independently under different + tenants with O(1) lookups per tenant. When ``tenant_id`` is ``None`` + (the default), prompts live in a global scope, preserving backward + compatibility with single-tenant usage. + + Note: This class is not thread-safe. It is designed to run within a + single-threaded async event loop, where all synchronous mutations + execute atomically. Do not share instances across OS threads without + external synchronization. + """ def __init__(self, warn_on_duplicate_prompts: bool = True): - self._prompts: dict[str, Prompt] = {} + self._prompts: dict[str | None, dict[str, Prompt]] = {} self.warn_on_duplicate_prompts = warn_on_duplicate_prompts - def get_prompt(self, name: str) -> Prompt | None: - """Get prompt by name.""" - return self._prompts.get(name) + def get_prompt(self, name: str, *, tenant_id: str | None = None) -> Prompt | None: + """Get prompt by name, optionally scoped to a tenant.""" + return self._prompts.get(tenant_id, {}).get(name) - def list_prompts(self) -> list[Prompt]: - """List all registered prompts.""" - return list(self._prompts.values()) + def list_prompts(self, *, tenant_id: str | None = None) -> list[Prompt]: + """List all registered prompts for a given tenant scope.""" + return list(self._prompts.get(tenant_id, {}).values()) def add_prompt( self, prompt: Prompt, + *, + tenant_id: str | None = None, ) -> Prompt: - """Add a prompt to the manager.""" - - # Check for duplicates - existing = self._prompts.get(prompt.name) + """Add a prompt to the manager, optionally scoped to a tenant.""" + scope = self._prompts.setdefault(tenant_id, {}) + existing = scope.get(prompt.name) if existing: if self.warn_on_duplicate_prompts: logger.warning(f"Prompt already exists: {prompt.name}") return existing - self._prompts[prompt.name] = prompt + scope[prompt.name] = prompt return prompt + def remove_prompt(self, name: str, *, tenant_id: str | None = None) -> None: + """Remove a prompt by name, optionally scoped to a tenant.""" + scope = self._prompts.get(tenant_id, {}) + if name not in scope: + raise ValueError(f"Unknown prompt: {name}") + del scope[name] + if not scope and tenant_id in self._prompts: + del self._prompts[tenant_id] + async def render_prompt( self, name: str, arguments: dict[str, Any] | None, context: Context[LifespanContextT, RequestT], + *, + tenant_id: str | None = None, ) -> list[Message]: - """Render a prompt by name with arguments.""" - prompt = self.get_prompt(name) + """Render a prompt by name with arguments, optionally scoped to a tenant.""" + prompt = self.get_prompt(name, tenant_id=tenant_id) if not prompt: raise ValueError(f"Unknown prompt: {name}") diff --git a/src/mcp/server/mcpserver/resources/resource_manager.py b/src/mcp/server/mcpserver/resources/resource_manager.py index 6bf17376d..01913ed5d 100644 --- a/src/mcp/server/mcpserver/resources/resource_manager.py +++ b/src/mcp/server/mcpserver/resources/resource_manager.py @@ -20,37 +20,52 @@ class ResourceManager: - """Manages MCPServer resources.""" - - def __init__(self, warn_on_duplicate_resources: bool = True): - self._resources: dict[str, Resource] = {} - self._templates: dict[str, ResourceTemplate] = {} + """Manages MCPServer resources with optional tenant-scoped storage. + + Resources and templates are stored in nested dicts: + ``{tenant_id: {uri_string: Resource}}`` and + ``{tenant_id: {uri_template: ResourceTemplate}}`` respectively. + This allows the same URI to exist independently under different tenants + with O(1) lookups per tenant. When ``tenant_id`` is ``None`` (the default), + entries live in a global scope, preserving backward compatibility + with single-tenant usage. + + Note: This class is not thread-safe. It is designed to run within a + single-threaded async event loop, where all synchronous mutations + execute atomically. Do not share instances across OS threads without + external synchronization. + """ + + def __init__(self, warn_on_duplicate_resources: bool = True, *, resources: list[Resource] | None = None): + self._resources: dict[str | None, dict[str, Resource]] = {} + self._templates: dict[str | None, dict[str, ResourceTemplate]] = {} self.warn_on_duplicate_resources = warn_on_duplicate_resources - def add_resource(self, resource: Resource) -> Resource: - """Add a resource to the manager. + for resource in resources or (): + self.add_resource(resource) + + def add_resource(self, resource: Resource, *, tenant_id: str | None = None) -> Resource: + """Add a resource to the manager, optionally scoped to a tenant. Args: resource: A Resource instance to add + tenant_id: Optional tenant scope for the resource Returns: - The added resource. If a resource with the same URI already exists, - returns the existing resource. + The added resource. If a resource with the same URI already exists, returns the existing resource. """ logger.debug( "Adding resource", - extra={ - "uri": resource.uri, - "type": type(resource).__name__, - "resource_name": resource.name, - }, + extra={"uri": resource.uri, "type": type(resource).__name__, "resource_name": resource.name}, ) - existing = self._resources.get(str(resource.uri)) + scope = self._resources.setdefault(tenant_id, {}) + uri_str = str(resource.uri) + existing = scope.get(uri_str) if existing: if self.warn_on_duplicate_resources: logger.warning(f"Resource already exists: {resource.uri}") return existing - self._resources[str(resource.uri)] = resource + scope[uri_str] = resource return resource def add_template( @@ -64,8 +79,15 @@ def add_template( icons: list[Icon] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, + *, + tenant_id: str | None = None, ) -> ResourceTemplate: - """Add a template from a function.""" + """Add a template from a function, optionally scoped to a tenant. + + Returns: + The added template. If a template with the same URI template already + exists, returns the existing template. + """ template = ResourceTemplate.from_function( fn, uri_template=uri_template, @@ -77,20 +99,43 @@ def add_template( annotations=annotations, meta=meta, ) - self._templates[template.uri_template] = template + scope = self._templates.setdefault(tenant_id, {}) + existing = scope.get(template.uri_template) + if existing: + if self.warn_on_duplicate_resources: + logger.warning(f"Resource template already exists: {template.uri_template}") + return existing + scope[template.uri_template] = template return template - async def get_resource(self, uri: AnyUrl | str, context: Context[LifespanContextT, RequestT]) -> Resource: + def remove_resource(self, uri: AnyUrl | str, *, tenant_id: str | None = None) -> None: + """Remove a resource by URI, optionally scoped to a tenant.""" + uri_str = str(uri) + scope = self._resources.get(tenant_id, {}) + if uri_str not in scope: + raise ValueError(f"Unknown resource: {uri}") + del scope[uri_str] + if not scope and tenant_id in self._resources: + del self._resources[tenant_id] + + async def get_resource( + self, + uri: AnyUrl | str, + context: Context[LifespanContextT, RequestT], + *, + tenant_id: str | 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}) # First check concrete resources - if resource := self._resources.get(uri_str): + resource = self._resources.get(tenant_id, {}).get(uri_str) + if resource: return resource - # Then check templates - for template in self._templates.values(): + # Then check templates for this tenant scope + for template in self._templates.get(tenant_id, {}).values(): if params := template.matches(uri_str): try: return await template.create_resource(uri_str, params, context=context) @@ -99,12 +144,14 @@ async def get_resource(self, uri: AnyUrl | str, context: Context[LifespanContext raise ValueError(f"Unknown resource: {uri}") - def list_resources(self) -> list[Resource]: - """List all registered resources.""" - logger.debug("Listing resources", extra={"count": len(self._resources)}) - return list(self._resources.values()) - - def list_templates(self) -> list[ResourceTemplate]: - """List all registered templates.""" - logger.debug("Listing templates", extra={"count": len(self._templates)}) - return list(self._templates.values()) + def list_resources(self, *, tenant_id: str | None = None) -> list[Resource]: + """List all registered resources for a given tenant scope.""" + resources = list(self._resources.get(tenant_id, {}).values()) + logger.debug("Listing resources", extra={"count": len(resources)}) + return resources + + def list_templates(self, *, tenant_id: str | None = None) -> list[ResourceTemplate]: + """List all registered templates for a given tenant scope.""" + templates = list(self._templates.get(tenant_id, {}).values()) + logger.debug("Listing templates", extra={"count": len(templates)}) + return templates diff --git a/src/mcp/server/mcpserver/resources/templates.py b/src/mcp/server/mcpserver/resources/templates.py index 2d612657c..f1ee29a37 100644 --- a/src/mcp/server/mcpserver/resources/templates.py +++ b/src/mcp/server/mcpserver/resources/templates.py @@ -2,17 +2,19 @@ from __future__ import annotations -import inspect +import functools import re from collections.abc import Callable from typing import TYPE_CHECKING, Any from urllib.parse import unquote +import anyio.to_thread from pydantic import BaseModel, Field, validate_call 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.shared._callable_inspection import is_async_callable from mcp.types import Annotations, Icon if TYPE_CHECKING: @@ -110,10 +112,11 @@ async def create_resource( # Add context to params if needed params = inject_context(self.fn, params, context, self.context_kwarg) - # Call function and check if result is a coroutine - result = self.fn(**params) - if inspect.iscoroutine(result): - result = await result + fn = self.fn + if is_async_callable(fn): + result = await fn(**params) + else: + result = await anyio.to_thread.run_sync(functools.partial(self.fn, **params)) return FunctionResource( uri=uri, # type: ignore diff --git a/src/mcp/server/mcpserver/resources/types.py b/src/mcp/server/mcpserver/resources/types.py index 42aecd6e3..d9e472e36 100644 --- a/src/mcp/server/mcpserver/resources/types.py +++ b/src/mcp/server/mcpserver/resources/types.py @@ -1,6 +1,7 @@ """Concrete resource implementations.""" -import inspect +from __future__ import annotations + import json from collections.abc import Callable from pathlib import Path @@ -14,6 +15,7 @@ from pydantic import Field, ValidationInfo, validate_call from mcp.server.mcpserver.resources.base import Resource +from mcp.shared._callable_inspection import is_async_callable from mcp.types import Annotations, Icon @@ -55,11 +57,11 @@ class FunctionResource(Resource): async def read(self) -> str | bytes: """Read the resource by calling the wrapped function.""" try: - # Call the function first to see if it returns a coroutine - result = self.fn() - # If it's a coroutine, await it - if inspect.iscoroutine(result): - result = await result + fn = self.fn + if is_async_callable(fn): + result = await fn() + else: + result = await anyio.to_thread.run_sync(self.fn) if isinstance(result, Resource): # pragma: no cover return await result.read() @@ -84,7 +86,7 @@ def from_function( icons: list[Icon] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, - ) -> "FunctionResource": + ) -> FunctionResource: """Create a FunctionResource from a function.""" func_name = name or fn.__name__ if func_name == "": # pragma: no cover diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 6f9bb0e28..4f585f3a7 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -140,6 +140,7 @@ def __init__( token_verifier: TokenVerifier | None = None, *, tools: list[Tool] | None = None, + resources: list[Resource] | None = None, debug: bool = False, log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO", warn_on_duplicate_resources: bool = True, @@ -162,7 +163,9 @@ def __init__( self.dependencies = self.settings.dependencies 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._resource_manager = ResourceManager( + resources=resources, 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", @@ -299,14 +302,14 @@ def run( async def _handle_list_tools( self, ctx: ServerRequestContext[LifespanResultT], params: PaginatedRequestParams | None ) -> ListToolsResult: - return ListToolsResult(tools=await self.list_tools()) + return ListToolsResult(tools=await self.list_tools(tenant_id=ctx.tenant_id)) async def _handle_call_tool( self, ctx: ServerRequestContext[LifespanResultT], params: CallToolRequestParams ) -> CallToolResult: context = Context(request_context=ctx, mcp_server=self) try: - result = await self.call_tool(params.name, params.arguments or {}, context) + result = await self.call_tool(params.name, params.arguments or {}, context, tenant_id=ctx.tenant_id) except MCPError: raise except Exception as e: @@ -332,13 +335,13 @@ async def _handle_call_tool( async def _handle_list_resources( self, ctx: ServerRequestContext[LifespanResultT], params: PaginatedRequestParams | None ) -> ListResourcesResult: - return ListResourcesResult(resources=await self.list_resources()) + return ListResourcesResult(resources=await self.list_resources(tenant_id=ctx.tenant_id)) async def _handle_read_resource( self, ctx: ServerRequestContext[LifespanResultT], params: ReadResourceRequestParams ) -> ReadResourceResult: context = Context(request_context=ctx, mcp_server=self) - results = await self.read_resource(params.uri, context) + results = await self.read_resource(params.uri, context, tenant_id=ctx.tenant_id) contents: list[TextResourceContents | BlobResourceContents] = [] for item in results: if isinstance(item.content, bytes): @@ -364,22 +367,24 @@ async def _handle_read_resource( async def _handle_list_resource_templates( self, ctx: ServerRequestContext[LifespanResultT], params: PaginatedRequestParams | None ) -> ListResourceTemplatesResult: - return ListResourceTemplatesResult(resource_templates=await self.list_resource_templates()) + return ListResourceTemplatesResult( + resource_templates=await self.list_resource_templates(tenant_id=ctx.tenant_id) + ) async def _handle_list_prompts( self, ctx: ServerRequestContext[LifespanResultT], params: PaginatedRequestParams | None ) -> ListPromptsResult: - return ListPromptsResult(prompts=await self.list_prompts()) + return ListPromptsResult(prompts=await self.list_prompts(tenant_id=ctx.tenant_id)) async def _handle_get_prompt( self, ctx: ServerRequestContext[LifespanResultT], params: GetPromptRequestParams ) -> GetPromptResult: context = Context(request_context=ctx, mcp_server=self) - return await self.get_prompt(params.name, params.arguments, context) + return await self.get_prompt(params.name, params.arguments, context, tenant_id=ctx.tenant_id) - async def list_tools(self) -> list[MCPTool]: + async def list_tools(self, *, tenant_id: str | None = None) -> list[MCPTool]: """List all available tools.""" - tools = self._tool_manager.list_tools() + tools = self._tool_manager.list_tools(tenant_id=tenant_id) return [ MCPTool( name=info.name, @@ -395,17 +400,22 @@ async def list_tools(self) -> list[MCPTool]: ] async def call_tool( - self, name: str, arguments: dict[str, Any], context: Context[LifespanResultT, Any] | None = None + self, + name: str, + arguments: dict[str, Any], + context: Context[LifespanResultT, Any] | None = None, + *, + tenant_id: str | None = None, ) -> Sequence[ContentBlock] | dict[str, Any]: """Call a tool by name with arguments.""" if context is None: context = Context(mcp_server=self) - return await self._tool_manager.call_tool(name, arguments, context, convert_result=True) + return await self._tool_manager.call_tool(name, arguments, context, convert_result=True, tenant_id=tenant_id) - async def list_resources(self) -> list[MCPResource]: + async def list_resources(self, *, tenant_id: str | None = None) -> list[MCPResource]: """List all available resources.""" - resources = self._resource_manager.list_resources() + resources = self._resource_manager.list_resources(tenant_id=tenant_id) return [ MCPResource( uri=resource.uri, @@ -420,8 +430,8 @@ async def list_resources(self) -> list[MCPResource]: for resource in resources ] - async def list_resource_templates(self) -> list[MCPResourceTemplate]: - templates = self._resource_manager.list_templates() + async def list_resource_templates(self, *, tenant_id: str | None = None) -> list[MCPResourceTemplate]: + templates = self._resource_manager.list_templates(tenant_id=tenant_id) return [ MCPResourceTemplate( uri_template=template.uri_template, @@ -437,13 +447,17 @@ async def list_resource_templates(self) -> list[MCPResourceTemplate]: ] async def read_resource( - self, uri: AnyUrl | str, context: Context[LifespanResultT, Any] | None = None + self, + uri: AnyUrl | str, + context: Context[LifespanResultT, Any] | None = None, + *, + tenant_id: str | None = None, ) -> Iterable[ReadResourceContents]: """Read a resource by URI.""" if context is None: context = Context(mcp_server=self) try: - resource = await self._resource_manager.get_resource(uri, context) + resource = await self._resource_manager.get_resource(uri, context, tenant_id=tenant_id) except ValueError: raise ResourceError(f"Unknown resource: {uri}") @@ -465,6 +479,8 @@ def add_tool( icons: list[Icon] | None = None, meta: dict[str, Any] | None = None, structured_output: bool | None = None, + *, + tenant_id: str | None = None, ) -> None: """Add a tool to the server. @@ -483,6 +499,7 @@ def add_tool( - 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 + tenant_id: Optional tenant scope for the tool """ self._tool_manager.add_tool( fn, @@ -493,18 +510,20 @@ def add_tool( icons=icons, meta=meta, structured_output=structured_output, + tenant_id=tenant_id, ) - def remove_tool(self, name: str) -> None: + def remove_tool(self, name: str, *, tenant_id: str | None = None) -> None: """Remove a tool from the server by name. Args: name: The name of the tool to remove + tenant_id: Optional tenant scope for the tool Raises: ToolError: If the tool does not exist """ - self._tool_manager.remove_tool(name) + self._tool_manager.remove_tool(name, tenant_id=tenant_id) def tool( self, @@ -613,13 +632,26 @@ async def handler( return decorator - def add_resource(self, resource: Resource) -> None: + def add_resource(self, resource: Resource, *, tenant_id: str | None = None) -> None: """Add a resource to the server. Args: resource: A Resource instance to add + tenant_id: Optional tenant scope for the resource """ - self._resource_manager.add_resource(resource) + self._resource_manager.add_resource(resource, tenant_id=tenant_id) + + def remove_resource(self, uri: AnyUrl | str, *, tenant_id: str | None = None) -> None: + """Remove a resource from the server by URI. + + Args: + uri: The URI of the resource to remove + tenant_id: Optional tenant scope for the resource + + Raises: + ValueError: If the resource does not exist + """ + self._resource_manager.remove_resource(uri, tenant_id=tenant_id) def resource( self, @@ -733,13 +765,26 @@ def decorator(fn: _CallableT) -> _CallableT: return decorator - def add_prompt(self, prompt: Prompt) -> None: + def add_prompt(self, prompt: Prompt, *, tenant_id: str | None = None) -> None: """Add a prompt to the server. Args: prompt: A Prompt instance to add + tenant_id: Optional tenant scope for the prompt + """ + self._prompt_manager.add_prompt(prompt, tenant_id=tenant_id) + + def remove_prompt(self, name: str, *, tenant_id: str | None = None) -> None: + """Remove a prompt from the server by name. + + Args: + name: The name of the prompt to remove + tenant_id: Optional tenant scope for the prompt + + Raises: + ValueError: If the prompt does not exist """ - self._prompt_manager.add_prompt(prompt) + self._prompt_manager.remove_prompt(name, tenant_id=tenant_id) def prompt( self, @@ -1066,9 +1111,9 @@ def streamable_http_app( debug=self.settings.debug, ) - async def list_prompts(self) -> list[MCPPrompt]: + async def list_prompts(self, *, tenant_id: str | None = None) -> list[MCPPrompt]: """List all available prompts.""" - prompts = self._prompt_manager.list_prompts() + prompts = self._prompt_manager.list_prompts(tenant_id=tenant_id) return [ MCPPrompt( name=prompt.name, @@ -1088,13 +1133,18 @@ async def list_prompts(self) -> list[MCPPrompt]: ] async def get_prompt( - self, name: str, arguments: dict[str, Any] | None = None, context: Context[LifespanResultT, Any] | None = None + self, + name: str, + arguments: dict[str, Any] | None = None, + context: Context[LifespanResultT, Any] | None = None, + *, + tenant_id: str | None = None, ) -> GetPromptResult: """Get a prompt by name with arguments.""" if context is None: context = Context(mcp_server=self) try: - prompt = self._prompt_manager.get_prompt(name) + prompt = self._prompt_manager.get_prompt(name, tenant_id=tenant_id) if not prompt: raise ValueError(f"Unknown prompt: {name}") diff --git a/src/mcp/server/mcpserver/tools/base.py b/src/mcp/server/mcpserver/tools/base.py index dc65be988..754313eb8 100644 --- a/src/mcp/server/mcpserver/tools/base.py +++ b/src/mcp/server/mcpserver/tools/base.py @@ -1,7 +1,5 @@ from __future__ import annotations -import functools -import inspect from collections.abc import Callable from functools import cached_property from typing import TYPE_CHECKING, Any @@ -11,6 +9,7 @@ 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._callable_inspection import is_async_callable from mcp.shared.exceptions import UrlElicitationRequiredError from mcp.shared.tool_name_validation import validate_and_warn_tool_name from mcp.types import Icon, ToolAnnotations @@ -63,7 +62,7 @@ def from_function( raise ValueError("You must provide a name for lambda functions") func_doc = description or fn.__doc__ or "" - is_async = _is_async_callable(fn) + is_async = is_async_callable(fn) if context_kwarg is None: # pragma: no branch context_kwarg = find_context_parameter(fn) @@ -118,12 +117,3 @@ async def run( 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: lax no cover - obj = obj.func - - return inspect.iscoroutinefunction(obj) or ( - callable(obj) and inspect.iscoroutinefunction(getattr(obj, "__call__", None)) - ) diff --git a/src/mcp/server/mcpserver/tools/tool_manager.py b/src/mcp/server/mcpserver/tools/tool_manager.py index 32ed54797..69f102eb8 100644 --- a/src/mcp/server/mcpserver/tools/tool_manager.py +++ b/src/mcp/server/mcpserver/tools/tool_manager.py @@ -16,30 +16,38 @@ class ToolManager: - """Manages MCPServer tools.""" + """Manages MCPServer tools with optional tenant-scoped storage. - def __init__( - self, - warn_on_duplicate_tools: bool = True, - *, - tools: list[Tool] | None = None, - ): - self._tools: dict[str, Tool] = {} + Tools are stored in a nested dict: ``{tenant_id: {tool_name: Tool}}``. + This allows the same tool name to exist independently under different + tenants with O(1) lookups per tenant. When ``tenant_id`` is ``None`` + (the default), tools live in a global scope, preserving backward + compatibility with single-tenant usage. + + Note: This class is not thread-safe. It is designed to run within a + single-threaded async event loop, where all synchronous mutations + execute atomically. Do not share instances across OS threads without + external synchronization. + """ + + def __init__(self, warn_on_duplicate_tools: bool = True, *, tools: list[Tool] | None = None): + self._tools: dict[str | None, dict[str, Tool]] = {} if tools is not None: + scope = self._tools.setdefault(None, {}) for tool in tools: - if warn_on_duplicate_tools and tool.name in self._tools: + if warn_on_duplicate_tools and tool.name in scope: logger.warning(f"Tool already exists: {tool.name}") - self._tools[tool.name] = tool + scope[tool.name] = tool self.warn_on_duplicate_tools = warn_on_duplicate_tools - def get_tool(self, name: str) -> Tool | None: - """Get tool by name.""" - return self._tools.get(name) + def get_tool(self, name: str, *, tenant_id: str | None = None) -> Tool | None: + """Get tool by name, optionally scoped to a tenant.""" + return self._tools.get(tenant_id, {}).get(name) - def list_tools(self) -> list[Tool]: - """List all registered tools.""" - return list(self._tools.values()) + def list_tools(self, *, tenant_id: str | None = None) -> list[Tool]: + """List all registered tools for a given tenant scope.""" + return list(self._tools.get(tenant_id, {}).values()) def add_tool( self, @@ -51,8 +59,10 @@ def add_tool( icons: list[Icon] | None = None, meta: dict[str, Any] | None = None, structured_output: bool | None = None, + *, + tenant_id: str | None = None, ) -> Tool: - """Add a tool to the server.""" + """Add a tool to the server, optionally scoped to a tenant.""" tool = Tool.from_function( fn, name=name, @@ -63,19 +73,23 @@ def add_tool( meta=meta, structured_output=structured_output, ) - existing = self._tools.get(tool.name) + scope = self._tools.setdefault(tenant_id, {}) + existing = scope.get(tool.name) if existing: if self.warn_on_duplicate_tools: logger.warning(f"Tool already exists: {tool.name}") return existing - self._tools[tool.name] = tool + scope[tool.name] = tool return tool - def remove_tool(self, name: str) -> None: - """Remove a tool by name.""" - if name not in self._tools: + def remove_tool(self, name: str, *, tenant_id: str | None = None) -> None: + """Remove a tool by name, optionally scoped to a tenant.""" + scope = self._tools.get(tenant_id, {}) + if name not in scope: raise ToolError(f"Unknown tool: {name}") - del self._tools[name] + del scope[name] + if not scope and tenant_id in self._tools: + del self._tools[tenant_id] async def call_tool( self, @@ -83,9 +97,11 @@ async def call_tool( arguments: dict[str, Any], context: Context[LifespanContextT, RequestT], convert_result: bool = False, + *, + tenant_id: str | None = None, ) -> Any: - """Call a tool by name with arguments.""" - tool = self.get_tool(name) + """Call a tool by name with arguments, optionally scoped to a tenant.""" + tool = self.get_tool(name, tenant_id=tenant_id) if not tool: raise ToolError(f"Unknown tool: {name}") diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index 062b47d0f..4a7610637 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -9,7 +9,7 @@ import anyio import anyio.to_thread import pydantic_core -from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, create_model +from pydantic import BaseModel, ConfigDict, Field, PydanticUserError, WithJsonSchema, create_model from pydantic.fields import FieldInfo from pydantic.json_schema import GenerateJsonSchema, JsonSchemaWarningKind from typing_extensions import is_typeddict @@ -402,9 +402,16 @@ def _try_create_model_and_schema( # Use StrictJsonSchema to raise exceptions instead of warnings try: schema = model.model_json_schema(schema_generator=StrictJsonSchema) - except (TypeError, ValueError, pydantic_core.SchemaError, pydantic_core.ValidationError) as e: + except ( + PydanticUserError, + TypeError, + ValueError, + pydantic_core.SchemaError, + pydantic_core.ValidationError, + ) as e: # These are expected errors when a type can't be converted to a Pydantic schema - # TypeError: When Pydantic can't handle the type + # PydanticUserError: When Pydantic can't handle the type (e.g. PydanticInvalidForJsonSchema); + # subclasses TypeError on pydantic <2.13 and RuntimeError on pydantic >=2.13 # ValueError: When there are issues with the type definition (including our custom warnings) # SchemaError: When Pydantic can't build a schema # ValidationError: When validation fails diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index ce467e6c9..25af4cef0 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -33,13 +33,14 @@ async def handle_list_prompts(ctx: RequestContext, params) -> ListPromptsResult: import anyio import anyio.lowlevel -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream +from anyio.streams.memory import MemoryObjectReceiveStream from pydantic import AnyUrl, TypeAdapter 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._stream_protocols import ReadStream, WriteStream 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 @@ -76,11 +77,12 @@ class ServerSession( _initialized: InitializationState = InitializationState.NotInitialized _client_params: types.InitializeRequestParams | None = None _experimental_features: ExperimentalServerSessionFeatures | None = None + _tenant_id: str | None = None def __init__( self, - read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], - write_stream: MemoryObjectSendStream[SessionMessage], + read_stream: ReadStream[SessionMessage | Exception], + write_stream: WriteStream[SessionMessage], init_options: InitializationOptions, stateless: bool = False, ) -> None: @@ -108,6 +110,27 @@ def _receive_notification_adapter(self) -> TypeAdapter[types.ClientNotification] def client_params(self) -> types.InitializeRequestParams | None: return self._client_params + @property + def tenant_id(self) -> str | None: + """Get the tenant_id for this session.""" + return self._tenant_id + + @tenant_id.setter + def tenant_id(self, value: str | None) -> None: + """Set the tenant_id for this session (set-once). + + Once a session is bound to a tenant, the tenant_id cannot be changed. + This prevents accidental tenant reassignment which could be a security issue. + + Raises: + ValueError: If tenant_id is already set to a different value. + """ + if self._tenant_id is not None and value != self._tenant_id: + raise ValueError( + f"Cannot change tenant_id from '{self._tenant_id}' to '{value}': session is already bound to a tenant" + ) + self._tenant_id = value + @property def experimental(self) -> ExperimentalServerSessionFeatures: """Experimental APIs for server→client task operations. diff --git a/src/mcp/server/sse.py b/src/mcp/server/sse.py index 9dcee67f7..48192ff61 100644 --- a/src/mcp/server/sse.py +++ b/src/mcp/server/sse.py @@ -43,7 +43,6 @@ async def handle_sse(request): from uuid import UUID, uuid4 import anyio -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from pydantic import ValidationError from sse_starlette import EventSourceResponse from starlette.requests import Request @@ -55,6 +54,7 @@ async def handle_sse(request): TransportSecurityMiddleware, TransportSecuritySettings, ) +from mcp.shared._context_streams import ContextSendStream, create_context_streams from mcp.shared.message import ServerMessageMetadata, SessionMessage logger = logging.getLogger(__name__) @@ -72,7 +72,7 @@ class SseServerTransport: """ _endpoint: str - _read_stream_writers: dict[UUID, MemoryObjectSendStream[SessionMessage | Exception]] + _read_stream_writers: dict[UUID, ContextSendStream[SessionMessage | Exception]] _security: TransportSecurityMiddleware def __init__(self, endpoint: str, security_settings: TransportSecuritySettings | None = None) -> None: @@ -129,14 +129,9 @@ async def connect_sse(self, scope: Scope, receive: Receive, send: Send): # prag raise ValueError("Request validation failed") logger.debug("Setting up SSE connection") - read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] - read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] - write_stream: MemoryObjectSendStream[SessionMessage] - write_stream_reader: MemoryObjectReceiveStream[SessionMessage] - - read_stream_writer, read_stream = anyio.create_memory_object_stream(0) - write_stream, write_stream_reader = anyio.create_memory_object_stream(0) + read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0) + write_stream, write_stream_reader = create_context_streams[SessionMessage](0) session_id = uuid4() self._read_stream_writers[session_id] = read_stream_writer diff --git a/src/mcp/server/stdio.py b/src/mcp/server/stdio.py index 5ea6c4e77..5c1459dff 100644 --- a/src/mcp/server/stdio.py +++ b/src/mcp/server/stdio.py @@ -23,9 +23,9 @@ async def run_server(): import anyio import anyio.lowlevel -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from mcp import types +from mcp.shared._context_streams import create_context_streams from mcp.shared.message import SessionMessage @@ -43,14 +43,8 @@ async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio. if not stdout: stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8")) - read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] - read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] - - write_stream: MemoryObjectSendStream[SessionMessage] - write_stream_reader: MemoryObjectReceiveStream[SessionMessage] - - read_stream_writer, read_stream = anyio.create_memory_object_stream(0) - write_stream, write_stream_reader = anyio.create_memory_object_stream(0) + read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0) + write_stream, write_stream_reader = create_context_streams[SessionMessage](0) async def stdin_reader(): try: diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index aa99e7c88..f14201857 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -25,6 +25,8 @@ from starlette.types import Receive, Scope, Send from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings +from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams +from mcp.shared._stream_protocols import ReadStream, WriteStream from mcp.shared.message import ServerMessageMetadata, SessionMessage from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS from mcp.types import ( @@ -119,10 +121,10 @@ class StreamableHTTPServerTransport: """ # Server notification streams for POST requests as well as standalone SSE stream - _read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] | None = None - _read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] | None = None - _write_stream: MemoryObjectSendStream[SessionMessage] | None = None - _write_stream_reader: MemoryObjectReceiveStream[SessionMessage] | None = None + _read_stream_writer: ContextSendStream[SessionMessage | Exception] | None = None + _read_stream: ContextReceiveStream[SessionMessage | Exception] | None = None + _write_stream: ContextSendStream[SessionMessage] | None = None + _write_stream_reader: ContextReceiveStream[SessionMessage] | None = None _security: TransportSecurityMiddleware def __init__( @@ -954,8 +956,8 @@ async def connect( self, ) -> AsyncGenerator[ tuple[ - MemoryObjectReceiveStream[SessionMessage | Exception], - MemoryObjectSendStream[SessionMessage], + ReadStream[SessionMessage | Exception], + WriteStream[SessionMessage], ], None, ]: @@ -967,8 +969,8 @@ async def connect( # Create the memory streams for this connection - 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) + read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0) + write_stream, write_stream_reader = create_context_streams[SessionMessage](0) # Store the streams self._read_stream_writer = read_stream_writer diff --git a/src/mcp/server/streamable_http_manager.py b/src/mcp/server/streamable_http_manager.py index c25314eab..cd2063e1b 100644 --- a/src/mcp/server/streamable_http_manager.py +++ b/src/mcp/server/streamable_http_manager.py @@ -21,6 +21,7 @@ StreamableHTTPServerTransport, ) from mcp.server.transport_security import TransportSecuritySettings +from mcp.shared._context import tenant_id_var from mcp.types import INVALID_REQUEST, ErrorData, JSONRPCError if TYPE_CHECKING: @@ -89,6 +90,7 @@ def __init__( # Session tracking (only used if not stateless) self._session_creation_lock = anyio.Lock() self._server_instances: dict[str, StreamableHTTPServerTransport] = {} + self._session_tenants: dict[str, str | None] = {} # The task group will be set during lifespan self._task_group = None @@ -135,6 +137,7 @@ async def lifespan(app: Starlette) -> AsyncIterator[None]: self._task_group = None # Clear any remaining server instances self._server_instances.clear() + self._session_tenants.clear() async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> None: """Process ASGI request with proper session handling and transport setup. @@ -194,6 +197,29 @@ async def _handle_stateful_request(self, scope: Scope, receive: Receive, send: S # Existing session case if request_mcp_session_id is not None and request_mcp_session_id in self._server_instances: + # Validate that the requesting tenant matches the session's tenant + session_tenant = self._session_tenants.get(request_mcp_session_id) + request_tenant = tenant_id_var.get() + if session_tenant is not None and request_tenant != session_tenant: + logger.warning("Tenant mismatch for session %s", request_mcp_session_id[:64]) + logger.debug( + "Tenant mismatch detail: session bound to '%s', request from '%s'", + session_tenant, + request_tenant, + ) + error_response = JSONRPCError( + jsonrpc="2.0", + id=None, + error=ErrorData(code=INVALID_REQUEST, message="Session not found"), + ) + response = Response( + 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) + return + transport = self._server_instances[request_mcp_session_id] logger.debug("Session already exists, handling request directly") # Push back idle deadline on activity @@ -217,6 +243,7 @@ async def _handle_stateful_request(self, scope: Scope, receive: Receive, send: S assert http_transport.mcp_session_id is not None self._server_instances[http_transport.mcp_session_id] = http_transport + self._session_tenants[http_transport.mcp_session_id] = tenant_id_var.get() logger.info(f"Created new transport with session ID: {new_session_id}") # Define the server runner @@ -246,6 +273,7 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE 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) + self._session_tenants.pop(http_transport.mcp_session_id, None) await http_transport.terminate() except Exception: logger.exception(f"Session {http_transport.mcp_session_id} crashed") @@ -260,6 +288,7 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE f"{http_transport.mcp_session_id} from active instances." ) del self._server_instances[http_transport.mcp_session_id] + self._session_tenants.pop(http_transport.mcp_session_id, None) # Assert task group is not None for type checking assert self._task_group is not None diff --git a/src/mcp/server/websocket.py b/src/mcp/server/websocket.py index 32b50560c..277f9b5af 100644 --- a/src/mcp/server/websocket.py +++ b/src/mcp/server/websocket.py @@ -1,12 +1,12 @@ from contextlib import asynccontextmanager import anyio -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from pydantic_core import ValidationError from starlette.types import Receive, Scope, Send from starlette.websockets import WebSocket from mcp import types +from mcp.shared._context_streams import create_context_streams from mcp.shared.message import SessionMessage @@ -19,14 +19,8 @@ async def websocket_server(scope: Scope, receive: Receive, send: Send): websocket = WebSocket(scope, receive, send) await websocket.accept(subprotocol="mcp") - read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] - read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] - - write_stream: MemoryObjectSendStream[SessionMessage] - write_stream_reader: MemoryObjectReceiveStream[SessionMessage] - - read_stream_writer, read_stream = anyio.create_memory_object_stream(0) - write_stream, write_stream_reader = anyio.create_memory_object_stream(0) + read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0) + write_stream, write_stream_reader = create_context_streams[SessionMessage](0) async def ws_reader(): try: diff --git a/src/mcp/shared/_callable_inspection.py b/src/mcp/shared/_callable_inspection.py new file mode 100644 index 000000000..0e89e446f --- /dev/null +++ b/src/mcp/shared/_callable_inspection.py @@ -0,0 +1,33 @@ +"""Callable inspection utilities. + +Adapted from Starlette's `is_async_callable` implementation. +https://github.com/encode/starlette/blob/main/starlette/_utils.py +""" + +from __future__ import annotations + +import functools +import inspect +from collections.abc import Awaitable, Callable +from typing import Any, TypeGuard, TypeVar, overload + +T = TypeVar("T") + +AwaitableCallable = Callable[..., Awaitable[T]] + + +@overload +def is_async_callable(obj: AwaitableCallable[T]) -> TypeGuard[AwaitableCallable[T]]: ... + + +@overload +def is_async_callable(obj: Any) -> TypeGuard[AwaitableCallable[Any]]: ... + + +def is_async_callable(obj: Any) -> Any: + while isinstance(obj, functools.partial): # pragma: lax no cover + obj = obj.func + + return inspect.iscoroutinefunction(obj) or ( + callable(obj) and inspect.iscoroutinefunction(getattr(obj, "__call__", None)) + ) diff --git a/src/mcp/shared/_context.py b/src/mcp/shared/_context.py index bbcee2d02..f3d5d55b6 100644 --- a/src/mcp/shared/_context.py +++ b/src/mcp/shared/_context.py @@ -1,5 +1,6 @@ """Request context for MCP handlers.""" +import contextvars from dataclasses import dataclass from typing import Any, Generic @@ -8,6 +9,31 @@ from mcp.shared.session import BaseSession from mcp.types import RequestId, RequestParamsMeta +# Transport-agnostic contextvar for tenant identification. +# Set by the transport layer (e.g., AuthContextMiddleware for HTTP+OAuth). +# Read by the core server to populate RequestContext.tenant_id. +tenant_id_var = contextvars.ContextVar[str | None]("tenant_id", default=None) + + +def merge_contexts( + sender_context: contextvars.Context, + server_context: contextvars.Context, +) -> contextvars.Context: + """Create a merged context: sender as base, server values overlaid. + + This ensures that client-side contextvars (e.g., OTel trace context) + propagate to handlers while server-side contextvars (e.g., ``tenant_id_var``) + take precedence. + """ + + def _build() -> contextvars.Context: + for var, val in server_context.items(): + var.set(val) + return contextvars.copy_context() + + return sender_context.run(_build) + + SessionT = TypeVar("SessionT", bound=BaseSession[Any, Any, Any, Any, Any]) @@ -17,8 +43,13 @@ class RequestContext(Generic[SessionT]): For request handlers, request_id is always populated. For notification handlers, request_id is None. + + The tenant_id field is used in multi-tenant server deployments to identify + which tenant the request belongs to. It is populated from session context + and enables tenant-specific request handling and isolation. """ session: SessionT request_id: RequestId | None = None meta: RequestParamsMeta | None = None + tenant_id: str | None = None diff --git a/src/mcp/shared/_context_streams.py b/src/mcp/shared/_context_streams.py new file mode 100644 index 000000000..04c33306d --- /dev/null +++ b/src/mcp/shared/_context_streams.py @@ -0,0 +1,119 @@ +"""Context-aware memory stream wrappers. + +anyio memory streams do not propagate ``contextvars.Context`` across task +boundaries. These thin wrappers capture the sender's context at ``send()`` +time and expose it on the receive side via ``last_context``, so consumers +can restore it with ``ctx.run(handler, item)``. + +The iteration interface is unchanged (yields ``T``, not tuples), keeping +these wrappers duck-type compatible with plain ``MemoryObjectSendStream`` +and ``MemoryObjectReceiveStream``. +""" + +from __future__ import annotations + +import contextvars +from types import TracebackType +from typing import Any, Generic, TypeVar + +import anyio +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream + +T = TypeVar("T") + +# Internal payload carried through the underlying raw stream. +_Envelope = tuple[contextvars.Context, T] + + +class ContextSendStream(Generic[T]): + """Send-side wrapper that snapshots ``contextvars.copy_context()`` on every ``send()``.""" + + __slots__ = ("_inner",) + + def __init__(self, inner: MemoryObjectSendStream[_Envelope[T]]) -> None: + self._inner = inner + + async def send(self, item: T) -> None: + await self._inner.send((contextvars.copy_context(), item)) + + def close(self) -> None: + self._inner.close() + + async def aclose(self) -> None: + await self._inner.aclose() + + def clone(self) -> ContextSendStream[T]: # pragma: no cover + return ContextSendStream(self._inner.clone()) + + async def __aenter__(self) -> ContextSendStream[T]: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool | None: + await self.aclose() + return None + + +class ContextReceiveStream(Generic[T]): + """Receive-side wrapper that yields ``T`` and stores the sender's context in ``last_context``.""" + + __slots__ = ("_inner", "last_context") + + def __init__(self, inner: MemoryObjectReceiveStream[_Envelope[T]]) -> None: + self._inner = inner + self.last_context: contextvars.Context | None = None + + async def receive(self) -> T: + ctx, item = await self._inner.receive() + self.last_context = ctx + return item + + def close(self) -> None: + self._inner.close() + + async def aclose(self) -> None: + await self._inner.aclose() + + def clone(self) -> ContextReceiveStream[T]: # pragma: no cover + return ContextReceiveStream(self._inner.clone()) + + def __aiter__(self) -> ContextReceiveStream[T]: + return self + + async def __anext__(self) -> T: + try: + return await self.receive() + except anyio.EndOfStream: + raise StopAsyncIteration + + async def __aenter__(self) -> ContextReceiveStream[T]: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool | None: + await self.aclose() + return None + + +class create_context_streams( + tuple[ContextSendStream[T], ContextReceiveStream[T]], +): + """Create context-aware memory object streams. + + Supports ``create_context_streams[T](n)`` bracket syntax, + matching anyio's ``create_memory_object_stream`` API style. + """ + + def __new__(cls, max_buffer_size: float = 0) -> tuple[ContextSendStream[T], ContextReceiveStream[T]]: # type: ignore[type-var] + raw_send: MemoryObjectSendStream[Any] + raw_receive: MemoryObjectReceiveStream[Any] + raw_send, raw_receive = anyio.create_memory_object_stream(max_buffer_size) + return (ContextSendStream(raw_send), ContextReceiveStream(raw_receive)) diff --git a/src/mcp/shared/_otel.py b/src/mcp/shared/_otel.py new file mode 100644 index 000000000..170e873a0 --- /dev/null +++ b/src/mcp/shared/_otel.py @@ -0,0 +1,36 @@ +"""OpenTelemetry helpers for MCP.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from typing import Any + +from opentelemetry.context import Context +from opentelemetry.propagate import extract, inject +from opentelemetry.trace import SpanKind, get_tracer + +_tracer = get_tracer("mcp-python-sdk") + + +@contextmanager +def otel_span( + name: str, + *, + kind: SpanKind, + attributes: dict[str, Any] | None = None, + context: Context | None = None, +) -> Iterator[Any]: + """Create an OTel span.""" + with _tracer.start_as_current_span(name, kind=kind, attributes=attributes, context=context) as span: + yield span + + +def inject_trace_context(meta: dict[str, Any]) -> None: + """Inject W3C trace context (traceparent/tracestate) into a `_meta` dict.""" + inject(meta) + + +def extract_trace_context(meta: dict[str, Any]) -> Context: + """Extract W3C trace context from a `_meta` dict.""" + return extract(meta) diff --git a/src/mcp/shared/_stream_protocols.py b/src/mcp/shared/_stream_protocols.py new file mode 100644 index 000000000..b79975132 --- /dev/null +++ b/src/mcp/shared/_stream_protocols.py @@ -0,0 +1,49 @@ +"""Stream protocols for MCP transports. + +These are general-purpose protocols satisfied by both ``MemoryObjectSendStream``/ +``MemoryObjectReceiveStream`` and the context-aware wrappers in ``_context_streams``. +""" + +from __future__ import annotations + +from types import TracebackType +from typing import Protocol, TypeVar + +from typing_extensions import Self + +T_co = TypeVar("T_co", covariant=True) +T_contra = TypeVar("T_contra", contravariant=True) + + +class ReadStream(Protocol[T_co]): + """Protocol for reading items from a stream. + + Consumers that need the sender's context should use + ``getattr(stream, 'last_context', None)``. + """ + + async def receive(self) -> T_co: ... + async def aclose(self) -> None: ... + def __aiter__(self) -> ReadStream[T_co]: ... + async def __anext__(self) -> T_co: ... + async def __aenter__(self) -> Self: ... + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool | None: ... + + +class WriteStream(Protocol[T_contra]): + """Protocol for writing items to a stream.""" + + async def send(self, item: T_contra, /) -> None: ... + async def aclose(self) -> None: ... + async def __aenter__(self) -> Self: ... + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool | None: ... diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index ca5b7b45a..ebf534d79 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -67,6 +67,24 @@ class OAuthClientMetadata(BaseModel): software_id: str | None = None software_version: str | None = None + @field_validator( + "client_uri", + "logo_uri", + "tos_uri", + "policy_uri", + "jwks_uri", + mode="before", + ) + @classmethod + def _empty_string_optional_url_to_none(cls, v: object) -> object: + # RFC 7591 §2 marks these URL fields OPTIONAL. Some authorization servers + # echo omitted metadata back as "" instead of dropping the keys, which + # AnyHttpUrl would otherwise reject — throwing away an otherwise valid + # registration response. Treat "" as absent. + if v == "": + return None + return v + def validate_scope(self, requested_scope: str | None) -> list[str] | None: if requested_scope is None: return None diff --git a/src/mcp/shared/memory.py b/src/mcp/shared/memory.py index f2d5e2b9a..468590d09 100644 --- a/src/mcp/shared/memory.py +++ b/src/mcp/shared/memory.py @@ -5,12 +5,10 @@ from collections.abc import AsyncGenerator from contextlib import asynccontextmanager -import anyio -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream - +from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams from mcp.shared.message import SessionMessage -MessageStream = tuple[MemoryObjectReceiveStream[SessionMessage | Exception], MemoryObjectSendStream[SessionMessage]] +MessageStream = tuple[ContextReceiveStream[SessionMessage | Exception], ContextSendStream[SessionMessage | Exception]] @asynccontextmanager @@ -22,8 +20,8 @@ async def create_client_server_memory_streams() -> AsyncGenerator[tuple[MessageS (read_stream, write_stream) """ # Create streams for both directions - server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage | Exception](1) - client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage | Exception](1) + server_to_client_send, server_to_client_receive = create_context_streams[SessionMessage | Exception](1) + client_to_server_send, client_to_server_receive = create_context_streams[SessionMessage | Exception](1) client_streams = (server_to_client_receive, client_to_server_send) server_streams = (client_to_server_receive, server_to_client_send) diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index 6fc59923f..243eef5ae 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextvars import logging from collections.abc import Callable from contextlib import AsyncExitStack @@ -7,10 +8,13 @@ from typing import Any, Generic, Protocol, TypeVar import anyio -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream +from anyio.streams.memory import MemoryObjectSendStream +from opentelemetry.trace import SpanKind from pydantic import BaseModel, TypeAdapter from typing_extensions import Self +from mcp.shared._otel import inject_trace_context, otel_span +from mcp.shared._stream_protocols import ReadStream, WriteStream from mcp.shared.exceptions import MCPError from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMessage from mcp.shared.response_router import ResponseRouter @@ -79,11 +83,13 @@ def __init__( session: BaseSession[SendRequestT, SendNotificationT, SendResultT, ReceiveRequestT, ReceiveNotificationT], on_complete: Callable[[RequestResponder[ReceiveRequestT, SendResultT]], Any], message_metadata: MessageMetadata = None, + context: contextvars.Context | None = None, ) -> None: self.request_id = request_id self.request_meta = request_meta self.request = request self.message_metadata = message_metadata + self.context = context self._session = session self._completed = False self._cancel_scope = anyio.CancelScope() @@ -181,8 +187,8 @@ class BaseSession( def __init__( self, - read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], - write_stream: MemoryObjectSendStream[SessionMessage], + read_stream: ReadStream[SessionMessage | Exception], + write_stream: WriteStream[SessionMessage], # If none, reading will never time out read_timeout_seconds: float | None = None, ) -> None: @@ -264,24 +270,36 @@ async def send_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=jsonrpc_request, metadata=metadata)) - - # request read timeout takes precedence over session read timeout - 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: - 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.from_jsonrpc_error(response_or_error) - else: - return result_type.model_validate(response_or_error.result, by_name=False) + target = request_data.get("params", {}).get("name") + span_name = f"MCP send {request.method} {target}" if target else f"MCP send {request.method}" + + with otel_span( + span_name, + kind=SpanKind.CLIENT, + attributes={"mcp.method.name": request.method, "jsonrpc.request.id": request_id}, + ): + # Inject W3C trace context into _meta (SEP-414). + meta: dict[str, Any] = request_data.setdefault("params", {}).setdefault("_meta", {}) + inject_trace_context(meta) + + 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 = 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: + 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.from_jsonrpc_error(response_or_error) + else: + return result_type.model_validate(response_or_error.result, by_name=False) finally: self._response_streams.pop(request_id, None) @@ -333,10 +351,10 @@ def _receive_notification_adapter(self) -> TypeAdapter[ReceiveNotificationT]: async def _receive_loop(self) -> None: async with self._read_stream, self._write_stream: try: - async for message in self._read_stream: - if isinstance(message, Exception): - await self._handle_incoming(message) - elif isinstance(message.message, JSONRPCRequest): + + async def _handle_session_message(message: SessionMessage) -> None: + sender_context: contextvars.Context | None = getattr(self._read_stream, "last_context", None) + if isinstance(message.message, JSONRPCRequest): try: validated_request = self._receive_request_adapter.validate_python( message.message.model_dump(by_alias=True, mode="json", exclude_none=True), @@ -349,6 +367,7 @@ async def _receive_loop(self) -> None: session=self, on_complete=lambda r: self._in_flight.pop(r.request_id, None), message_metadata=message.metadata, + context=sender_context, ) self._in_flight[responder.request_id] = responder await self._received_request(responder) @@ -406,6 +425,13 @@ async def _receive_loop(self) -> None: else: # Response or error await self._handle_response(message) + async for message in self._read_stream: + if isinstance(message, Exception): + await self._handle_incoming(message) + continue + + await _handle_session_message(message) + except anyio.ClosedResourceError: # This is expected when the client disconnects abruptly. # Without this handler, the exception would propagate up and diff --git a/tests/client/conftest.py b/tests/client/conftest.py index 2e39f1363..081e1d68e 100644 --- a/tests/client/conftest.py +++ b/tests/client/conftest.py @@ -4,15 +4,15 @@ from unittest.mock import patch import pytest -from anyio.streams.memory import MemoryObjectSendStream import mcp.shared.memory +from mcp.client._transport import WriteStream from mcp.shared.message import SessionMessage from mcp.types import JSONRPCNotification, JSONRPCRequest class SpyMemoryObjectSendStream: - def __init__(self, original_stream: MemoryObjectSendStream[SessionMessage]): + def __init__(self, original_stream: WriteStream[SessionMessage]): self.original_stream = original_stream self.sent_messages: list[SessionMessage] = [] diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 5aa985e36..bb0bce4c9 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -2264,3 +2264,357 @@ async def callback_handler() -> tuple[str, str | None]: await auth_flow.asend(final_response) except StopAsyncIteration: pass + + +class TestSEP2207OfflineAccessScope: + """Test SEP-2207: offline_access scope augmentation for OIDC-flavored refresh tokens.""" + + def _make_as_metadata(self, scopes_supported: list[str] | None = None) -> OAuthMetadata: + return OAuthMetadata( + issuer=AnyHttpUrl("https://auth.example.com"), + authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"), + token_endpoint=AnyHttpUrl("https://auth.example.com/token"), + scopes_supported=scopes_supported, + ) + + def _make_prm(self, scopes_supported: list[str] | None = None) -> ProtectedResourceMetadata: + return ProtectedResourceMetadata( + resource=AnyHttpUrl("https://api.example.com/v1/mcp"), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + scopes_supported=scopes_supported, + ) + + def test_offline_access_added_when_as_supports_and_client_has_refresh_token(self): + """offline_access is appended when AS advertises it and client supports refresh_token grant.""" + prm = self._make_prm(scopes_supported=["read", "write"]) + asm = self._make_as_metadata(scopes_supported=["read", "write", "offline_access"]) + + scopes = get_client_metadata_scopes( + www_authenticate_scope=None, + protected_resource_metadata=prm, + authorization_server_metadata=asm, + client_grant_types=["authorization_code", "refresh_token"], + ) + assert scopes == "read write offline_access" + + def test_offline_access_added_with_www_authenticate_scope(self): + """offline_access is appended even when scopes come from WWW-Authenticate header.""" + asm = self._make_as_metadata(scopes_supported=["read", "write", "offline_access"]) + + scopes = get_client_metadata_scopes( + www_authenticate_scope="read write", + protected_resource_metadata=None, + authorization_server_metadata=asm, + client_grant_types=["authorization_code", "refresh_token"], + ) + assert scopes == "read write offline_access" + + def test_offline_access_not_added_when_as_does_not_support(self): + """offline_access is not added when AS does not advertise it in scopes_supported.""" + prm = self._make_prm(scopes_supported=["read", "write"]) + asm = self._make_as_metadata(scopes_supported=["read", "write"]) + + scopes = get_client_metadata_scopes( + www_authenticate_scope=None, + protected_resource_metadata=prm, + authorization_server_metadata=asm, + client_grant_types=["authorization_code", "refresh_token"], + ) + assert scopes == "read write" + + def test_offline_access_not_added_when_client_has_no_refresh_token_grant(self): + """offline_access is not added when client does not support refresh_token grant.""" + prm = self._make_prm(scopes_supported=["read", "write"]) + asm = self._make_as_metadata(scopes_supported=["read", "write", "offline_access"]) + + scopes = get_client_metadata_scopes( + www_authenticate_scope=None, + protected_resource_metadata=prm, + authorization_server_metadata=asm, + client_grant_types=["authorization_code"], + ) + assert scopes == "read write" + + def test_offline_access_not_duplicated_when_already_present(self): + """offline_access is not added again if it already appears in the selected scopes.""" + prm = self._make_prm(scopes_supported=["read", "offline_access", "write"]) + asm = self._make_as_metadata(scopes_supported=["read", "write", "offline_access"]) + + scopes = get_client_metadata_scopes( + www_authenticate_scope=None, + protected_resource_metadata=prm, + authorization_server_metadata=asm, + client_grant_types=["authorization_code", "refresh_token"], + ) + assert scopes == "read offline_access write" + + def test_offline_access_not_added_when_no_scopes_selected(self): + """offline_access is not added when no base scopes are available (None).""" + asm = self._make_as_metadata(scopes_supported=["offline_access"]) + + scopes = get_client_metadata_scopes( + www_authenticate_scope=None, + protected_resource_metadata=None, + authorization_server_metadata=asm, + client_grant_types=["authorization_code", "refresh_token"], + ) + # When AS scopes are the only source and include offline_access, + # the base scope is "offline_access" and no duplication happens + assert scopes == "offline_access" + + def test_offline_access_not_added_when_as_scopes_supported_is_none(self): + """offline_access is not added when AS scopes_supported is None.""" + prm = self._make_prm(scopes_supported=["read", "write"]) + asm = self._make_as_metadata(scopes_supported=None) + + scopes = get_client_metadata_scopes( + www_authenticate_scope=None, + protected_resource_metadata=prm, + authorization_server_metadata=asm, + client_grant_types=["authorization_code", "refresh_token"], + ) + assert scopes == "read write" + + def test_offline_access_not_added_when_no_as_metadata(self): + """offline_access is not added when AS metadata is not available.""" + prm = self._make_prm(scopes_supported=["read", "write"]) + + scopes = get_client_metadata_scopes( + www_authenticate_scope=None, + protected_resource_metadata=prm, + authorization_server_metadata=None, + client_grant_types=["authorization_code", "refresh_token"], + ) + assert scopes == "read write" + + def test_offline_access_not_added_when_no_grant_types_provided(self): + """offline_access is not added when client_grant_types is None.""" + prm = self._make_prm(scopes_supported=["read", "write"]) + asm = self._make_as_metadata(scopes_supported=["read", "write", "offline_access"]) + + scopes = get_client_metadata_scopes( + www_authenticate_scope=None, + protected_resource_metadata=prm, + authorization_server_metadata=asm, + client_grant_types=None, + ) + assert scopes == "read write" + + def test_default_client_metadata_includes_refresh_token_grant(self): + """Default OAuthClientMetadata includes refresh_token in grant_types (SEP-2207 guidance).""" + metadata = OAuthClientMetadata(redirect_uris=[AnyUrl("http://localhost:3030/callback")]) + assert "refresh_token" in metadata.grant_types + + @pytest.mark.anyio + async def test_auth_flow_adds_offline_access_when_as_advertises( + self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage + ): + """E2E: auth flow includes offline_access in authorization request when AS advertises it.""" + + captured_auth_url: str | None = None + captured_state: str | None = None + + async def redirect_handler(url: str) -> None: + nonlocal captured_auth_url, captured_state + captured_auth_url = url + parsed = urlparse(url) + params = parse_qs(parsed.query) + captured_state = params.get("state", [None])[0] + + async def callback_handler() -> tuple[str, str | None]: + return "test_auth_code", captured_state + + provider = OAuthClientProvider( + server_url="https://api.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + redirect_handler=redirect_handler, + callback_handler=callback_handler, + ) + + provider.context.current_tokens = None + provider.context.token_expiry_time = None + provider._initialized = True + + # Pre-set client info to skip DCR + provider.context.client_info = OAuthClientInformationFull( + client_id="test_client", + client_secret="test_secret", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + ) + + test_request = httpx.Request("GET", "https://api.example.com/v1/mcp") + auth_flow = provider.async_auth_flow(test_request) + + # First request + request = await auth_flow.__anext__() + assert "Authorization" not in request.headers + + # Send 401 + response = httpx.Response(401, headers={}, request=test_request) + + # PRM discovery + prm_request = await auth_flow.asend(response) + prm_response = httpx.Response( + 200, + content=( + b'{"resource": "https://api.example.com/v1/mcp",' + b' "authorization_servers": ["https://auth.example.com"],' + b' "scopes_supported": ["read", "write"]}' + ), + request=prm_request, + ) + + # OAuth metadata discovery - AS advertises offline_access + oauth_request = await auth_flow.asend(prm_response) + oauth_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' "scopes_supported": ["read", "write", "offline_access"]}' + ), + request=oauth_request, + ) + + # This triggers authorization, which calls redirect_handler + token_request = await auth_flow.asend(oauth_response) + + # Verify the authorization URL included offline_access in the scope + assert captured_auth_url is not None + parsed = urlparse(captured_auth_url) + params = parse_qs(parsed.query) + scope_value = params["scope"][0] + scope_parts = scope_value.split() + assert "offline_access" in scope_parts + assert "read" in scope_parts + assert "write" in scope_parts + + # OIDC requires prompt=consent when offline_access is requested + assert params["prompt"][0] == "consent" + + # Complete the token exchange + token_response = httpx.Response( + 200, + content=( + b'{"access_token": "new_access_token", "token_type": "Bearer",' + b' "expires_in": 3600, "refresh_token": "new_refresh_token"}' + ), + request=token_request, + ) + + final_request = await auth_flow.asend(token_response) + assert final_request.headers["Authorization"] == "Bearer new_access_token" + + # Close the generator + final_response = httpx.Response(200, request=final_request) + try: + await auth_flow.asend(final_response) + except StopAsyncIteration: + pass + + @pytest.mark.anyio + async def test_auth_flow_no_offline_access_when_as_does_not_advertise( + self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage + ): + """E2E: auth flow does NOT include offline_access when AS doesn't advertise it.""" + + captured_auth_url: str | None = None + captured_state: str | None = None + + async def redirect_handler(url: str) -> None: + nonlocal captured_auth_url, captured_state + captured_auth_url = url + parsed = urlparse(url) + params = parse_qs(parsed.query) + captured_state = params.get("state", [None])[0] + + async def callback_handler() -> tuple[str, str | None]: + return "test_auth_code", captured_state + + provider = OAuthClientProvider( + server_url="https://api.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + redirect_handler=redirect_handler, + callback_handler=callback_handler, + ) + + provider.context.current_tokens = None + provider.context.token_expiry_time = None + provider._initialized = True + + # Pre-set client info to skip DCR + provider.context.client_info = OAuthClientInformationFull( + client_id="test_client", + client_secret="test_secret", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + ) + + test_request = httpx.Request("GET", "https://api.example.com/v1/mcp") + auth_flow = provider.async_auth_flow(test_request) + + # First request + await auth_flow.__anext__() + + # Send 401 + response = httpx.Response(401, headers={}, request=test_request) + + # PRM discovery + prm_request = await auth_flow.asend(response) + prm_response = httpx.Response( + 200, + content=( + b'{"resource": "https://api.example.com/v1/mcp",' + b' "authorization_servers": ["https://auth.example.com"],' + b' "scopes_supported": ["read", "write"]}' + ), + request=prm_request, + ) + + # OAuth metadata discovery - AS does NOT advertise offline_access + oauth_request = await auth_flow.asend(prm_response) + oauth_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' "scopes_supported": ["read", "write"]}' + ), + request=oauth_request, + ) + + # This triggers authorization, which calls redirect_handler + token_request = await auth_flow.asend(oauth_response) + + # Verify the authorization URL does NOT include offline_access + assert captured_auth_url is not None + parsed = urlparse(captured_auth_url) + params = parse_qs(parsed.query) + scope_value = params["scope"][0] + scope_parts = scope_value.split() + assert "offline_access" not in scope_parts + assert "read" in scope_parts + assert "write" in scope_parts + + # prompt=consent should NOT be present without offline_access + assert "prompt" not in params + + # Complete the token exchange + token_response = httpx.Response( + 200, + content=b'{"access_token": "new_access_token", "token_type": "Bearer", "expires_in": 3600}', + request=token_request, + ) + + final_request = await auth_flow.asend(token_response) + assert final_request.headers["Authorization"] == "Bearer new_access_token" + + # Close the generator + final_response = httpx.Response(200, request=final_request) + try: + await auth_flow.asend(final_response) + except StopAsyncIteration: + pass diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 18368e6bb..ac52a9024 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -2,6 +2,9 @@ from __future__ import annotations +import contextvars +from collections.abc import Iterator +from contextlib import contextmanager from unittest.mock import patch import anyio @@ -320,3 +323,33 @@ async def test_client_uses_transport_directly(app: MCPServer): structured_content={"result": "Hello, Transport!"}, ) ) + + +_TEST_CONTEXTVAR = contextvars.ContextVar("test_var", default="initial") + + +@contextmanager +def _set_test_contextvar(value: str) -> Iterator[None]: + token = _TEST_CONTEXTVAR.set(value) + try: + yield + finally: + _TEST_CONTEXTVAR.reset(token) + + +async def test_context_propagation(): + """Sender's contextvars.Context is propagated to the server handler.""" + server = MCPServer("test") + + @server.tool() + async def check_context() -> str: + """Return the contextvar value visible to the handler.""" + return _TEST_CONTEXTVAR.get() + + async with Client(server) as client: + with _set_test_contextvar("client_value"): + result = await client.call_tool("check_context", {}) + + assert result.content[0].text == "client_value", ( # type: ignore[union-attr] + "Server handler did not see the sender's contextvars.Context" + ) diff --git a/tests/client/test_logging_callback.py b/tests/client/test_logging_callback.py index 1598fd55f..454c1d338 100644 --- a/tests/client/test_logging_callback.py +++ b/tests/client/test_logging_callback.py @@ -1,4 +1,4 @@ -from typing import Any, Literal +from typing import Literal import pytest @@ -36,24 +36,20 @@ async def test_tool_with_log( message: str, level: Literal["debug", "info", "warning", "error"], logger: str, ctx: Context ) -> bool: """Send a log notification to the client.""" - await ctx.log(level=level, message=message, logger_name=logger) + await ctx.log(level=level, data=message, logger_name=logger) return True - @server.tool("test_tool_with_log_extra") - async def test_tool_with_log_extra( - message: str, + @server.tool("test_tool_with_log_dict") + async def test_tool_with_log_dict( level: Literal["debug", "info", "warning", "error"], logger: str, - extra_string: str, - extra_dict: dict[str, Any], ctx: Context, ) -> bool: - """Send a log notification to the client with extra fields.""" + """Send a log notification with a dict payload.""" await ctx.log( level=level, - message=message, + data={"message": "Test log message", "extra_string": "example", "extra_dict": {"a": 1, "b": 2, "c": 3}}, logger_name=logger, - extra={"extra_string": extra_string, "extra_dict": extra_dict}, ) return True @@ -84,18 +80,15 @@ async def message_handler( "logger": "test_logger", }, ) - log_result_with_extra = await client.call_tool( - "test_tool_with_log_extra", + log_result_with_dict = await client.call_tool( + "test_tool_with_log_dict", { - "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 log_result_with_dict.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] @@ -103,10 +96,10 @@ async def message_handler( 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 == { + log_with_dict = logging_collector.log_messages[1] + assert log_with_dict.level == "info" + assert log_with_dict.logger == "test_logger" + assert log_with_dict.data == { "message": "Test log message", "extra_string": "example", "extra_dict": {"a": 1, "b": 2, "c": 3}, diff --git a/tests/client/transports/test_memory.py b/tests/client/transports/test_memory.py index c8fc41fd5..e68e6e0f2 100644 --- a/tests/client/transports/test_memory.py +++ b/tests/client/transports/test_memory.py @@ -1,5 +1,7 @@ """Tests for InMemoryTransport.""" +import sys + import pytest from mcp import Client, types @@ -81,6 +83,8 @@ async def test_list_tools(mcpserver_server: MCPServer): assert "greet" in tool_names +@pytest.mark.filterwarnings("ignore::ResourceWarning" if sys.platform == "win32" else "default") +@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning" if sys.platform == "win32" else "default") async def test_call_tool(mcpserver_server: MCPServer): """Test calling a tool through the transport.""" async with Client(mcpserver_server) as client: diff --git a/tests/server/auth/middleware/test_auth_context.py b/tests/server/auth/middleware/test_auth_context.py index 66481bcf7..74736ef5b 100644 --- a/tests/server/auth/middleware/test_auth_context.py +++ b/tests/server/auth/middleware/test_auth_context.py @@ -9,9 +9,11 @@ AuthContextMiddleware, auth_context_var, get_access_token, + get_tenant_id, ) from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser from mcp.server.auth.provider import AccessToken +from mcp.shared._context import tenant_id_var class MockApp: @@ -117,3 +119,129 @@ async def send(message: Message) -> None: # pragma: no cover # Verify context is still empty after middleware assert auth_context_var.get() is None assert get_access_token() is None + + +@pytest.fixture +def access_token_with_tenant() -> AccessToken: + """Create an access token with a tenant_id.""" + return AccessToken( + token="tenant_token", + client_id="test_client", + scopes=["read", "write"], + expires_at=int(time.time()) + 3600, + tenant_id="tenant-abc", + ) + + +def test_get_tenant_id_without_auth_context(): + """Test get_tenant_id returns None when no auth context exists.""" + assert auth_context_var.get() is None + assert get_tenant_id() is None + + +@pytest.mark.anyio +async def test_get_tenant_id_with_tenant(access_token_with_tenant: AccessToken): + """Test get_tenant_id returns tenant_id when auth context has a tenant.""" + user = AuthenticatedUser(access_token_with_tenant) + scope: Scope = {"type": "http", "user": user} + + tenant_id_during_call: str | None = None + + class TenantCheckApp: + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + nonlocal tenant_id_during_call + tenant_id_during_call = get_tenant_id() + + middleware = AuthContextMiddleware(TenantCheckApp()) + + async def receive() -> Message: # pragma: no cover + return {"type": "http.request"} + + async def send(message: Message) -> None: # pragma: no cover + pass + + await middleware(scope, receive, send) + + assert tenant_id_during_call == "tenant-abc" + # Verify context is reset after middleware + assert get_tenant_id() is None + + +@pytest.mark.anyio +async def test_middleware_sets_tenant_id_var(access_token_with_tenant: AccessToken): + """Test AuthContextMiddleware populates the transport-agnostic tenant_id_var.""" + user = AuthenticatedUser(access_token_with_tenant) + scope: Scope = {"type": "http", "user": user} + + observed_tenant_id: str | None = None + + class CheckApp: + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + nonlocal observed_tenant_id + observed_tenant_id = tenant_id_var.get() + + middleware = AuthContextMiddleware(CheckApp()) + + async def receive() -> Message: # pragma: no cover + return {"type": "http.request"} + + async def send(message: Message) -> None: # pragma: no cover + pass + + await middleware(scope, receive, send) + + assert observed_tenant_id == "tenant-abc" + # Verify contextvar is reset after middleware + assert tenant_id_var.get() is None + + +@pytest.mark.anyio +async def test_middleware_sets_tenant_id_var_none_without_tenant(valid_access_token: AccessToken): + """Test AuthContextMiddleware sets tenant_id_var to None when token has no tenant.""" + user = AuthenticatedUser(valid_access_token) + scope: Scope = {"type": "http", "user": user} + + observed_tenant_id: str | None = "sentinel" + + class CheckApp: + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + nonlocal observed_tenant_id + observed_tenant_id = tenant_id_var.get() + + middleware = AuthContextMiddleware(CheckApp()) + + async def receive() -> Message: # pragma: no cover + return {"type": "http.request"} + + async def send(message: Message) -> None: # pragma: no cover + pass + + await middleware(scope, receive, send) + + assert observed_tenant_id is None + + +@pytest.mark.anyio +async def test_get_tenant_id_without_tenant(valid_access_token: AccessToken): + """Test get_tenant_id returns None when auth context has no tenant.""" + tenant_id_during_call: str | None = "not-none" + + class TenantCheckApp: + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + nonlocal tenant_id_during_call + tenant_id_during_call = get_tenant_id() + + middleware = AuthContextMiddleware(TenantCheckApp()) + + user = AuthenticatedUser(valid_access_token) + scope: Scope = {"type": "http", "user": user} + + async def receive() -> Message: # pragma: no cover + return {"type": "http.request"} + + async def send(message: Message) -> None: # pragma: no cover + pass + + await middleware(scope, receive, send) + + assert tenant_id_during_call is None diff --git a/tests/server/auth/test_multi_tenancy_tokens.py b/tests/server/auth/test_multi_tenancy_tokens.py new file mode 100644 index 000000000..a3764f316 --- /dev/null +++ b/tests/server/auth/test_multi_tenancy_tokens.py @@ -0,0 +1,170 @@ +"""Tests for multi-tenancy support in authentication token models.""" + +import pytest +from pydantic import AnyUrl + +from mcp.server.auth.provider import AccessToken, AuthorizationCode, RefreshToken + + +def test_authorization_code_with_tenant_id(): + """Test AuthorizationCode creation with tenant_id.""" + code = AuthorizationCode( + code="test_code", + scopes=["read", "write"], + expires_at=1234567890.0, + client_id="test_client", + code_challenge="challenge123", + redirect_uri=AnyUrl("http://localhost:8000/callback"), + redirect_uri_provided_explicitly=True, + tenant_id="tenant-abc", + ) + assert code.tenant_id == "tenant-abc" + assert code.code == "test_code" + assert code.scopes == ["read", "write"] + + +def test_authorization_code_without_tenant_id(): + """Test AuthorizationCode backward compatibility without tenant_id.""" + code = AuthorizationCode( + code="test_code", + scopes=["read"], + expires_at=1234567890.0, + client_id="test_client", + code_challenge="challenge123", + redirect_uri=AnyUrl("http://localhost:8000/callback"), + redirect_uri_provided_explicitly=False, + ) + assert code.tenant_id is None + + +def test_authorization_code_serialization_with_tenant_id(): + """Test AuthorizationCode serialization includes tenant_id.""" + code = AuthorizationCode( + code="test_code", + scopes=["read"], + expires_at=1234567890.0, + client_id="test_client", + code_challenge="challenge123", + redirect_uri=AnyUrl("http://localhost:8000/callback"), + redirect_uri_provided_explicitly=True, + tenant_id="tenant-xyz", + ) + data = code.model_dump() + assert data["tenant_id"] == "tenant-xyz" + + # Verify deserialization + restored = AuthorizationCode.model_validate(data) + assert restored.tenant_id == "tenant-xyz" + + +def test_refresh_token_with_tenant_id(): + """Test RefreshToken creation with tenant_id.""" + token = RefreshToken( + token="refresh_token_123", + client_id="test_client", + scopes=["read", "write"], + tenant_id="tenant-abc", + ) + assert token.tenant_id == "tenant-abc" + assert token.token == "refresh_token_123" + + +def test_refresh_token_without_tenant_id(): + """Test RefreshToken backward compatibility without tenant_id.""" + token = RefreshToken( + token="refresh_token_123", + client_id="test_client", + scopes=["read"], + ) + assert token.tenant_id is None + + +def test_refresh_token_serialization_with_tenant_id(): + """Test RefreshToken serialization includes tenant_id.""" + token = RefreshToken( + token="refresh_token_123", + client_id="test_client", + scopes=["read"], + expires_at=1234567890, + tenant_id="tenant-xyz", + ) + data = token.model_dump() + assert data["tenant_id"] == "tenant-xyz" + + # Verify deserialization + restored = RefreshToken.model_validate(data) + assert restored.tenant_id == "tenant-xyz" + + +def test_access_token_with_tenant_id(): + """Test AccessToken creation with tenant_id.""" + token = AccessToken( + token="access_token_123", + client_id="test_client", + scopes=["read", "write"], + tenant_id="tenant-abc", + ) + assert token.tenant_id == "tenant-abc" + assert token.token == "access_token_123" + + +def test_access_token_without_tenant_id(): + """Test AccessToken backward compatibility without tenant_id.""" + token = AccessToken( + token="access_token_123", + client_id="test_client", + scopes=["read"], + ) + assert token.tenant_id is None + + +def test_access_token_serialization_with_tenant_id(): + """Test AccessToken serialization includes tenant_id.""" + token = AccessToken( + token="access_token_123", + client_id="test_client", + scopes=["read"], + expires_at=1234567890, + resource="https://api.example.com", + tenant_id="tenant-xyz", + ) + data = token.model_dump() + assert data["tenant_id"] == "tenant-xyz" + + # Verify deserialization + restored = AccessToken.model_validate(data) + assert restored.tenant_id == "tenant-xyz" + + +def test_access_token_with_resource_and_tenant_id(): + """Test AccessToken with both resource (RFC 8707) and tenant_id.""" + token = AccessToken( + token="access_token_123", + client_id="test_client", + scopes=["read"], + resource="https://api.example.com", + tenant_id="tenant-abc", + ) + assert token.resource == "https://api.example.com" + assert token.tenant_id == "tenant-abc" + + +@pytest.mark.parametrize( + "tenant_id", + [ + "tenant-123", + "org_abc_def", + "a" * 100, # Long tenant ID + "tenant-with-dashes", + "tenant.with.dots", + ], +) +def test_access_token_various_tenant_id_formats(tenant_id: str): + """Test AccessToken accepts various tenant_id formats.""" + token = AccessToken( + token="access_token_123", + client_id="test_client", + scopes=["read"], + tenant_id=tenant_id, + ) + assert token.tenant_id == tenant_id diff --git a/tests/server/mcpserver/conftest.py b/tests/server/mcpserver/conftest.py new file mode 100644 index 000000000..993f91250 --- /dev/null +++ b/tests/server/mcpserver/conftest.py @@ -0,0 +1,22 @@ +from collections.abc import Callable +from typing import Any + +import pytest + +from mcp.server.mcpserver.context import Context + +MakeContext = Callable[..., Context[Any, Any]] + + +@pytest.fixture +def make_context() -> MakeContext: + """Factory fixture for creating Context instances in tests. + + Centralizes Context construction so that tests don't break if the + Context.__init__ signature changes in later iterations. + """ + + def _make(**kwargs: Any) -> Context[Any, Any]: + return Context(**kwargs) + + return _make diff --git a/tests/server/mcpserver/prompts/test_base.py b/tests/server/mcpserver/prompts/test_base.py index fe18e91bd..d4e4e6b5a 100644 --- a/tests/server/mcpserver/prompts/test_base.py +++ b/tests/server/mcpserver/prompts/test_base.py @@ -1,3 +1,4 @@ +import threading from typing import Any import pytest @@ -190,3 +191,21 @@ async def fn() -> dict[str, Any]: ) ) ] + + +@pytest.mark.anyio +async def test_sync_fn_runs_in_worker_thread(): + """Sync prompt functions must run in a worker thread, not the event loop.""" + + main_thread = threading.get_ident() + fn_thread: list[int] = [] + + def blocking_fn() -> str: + fn_thread.append(threading.get_ident()) + return "hello" + + prompt = Prompt.from_function(blocking_fn) + messages = await prompt.render(None, Context()) + + assert messages == [UserMessage(content=TextContent(type="text", text="hello"))] + assert fn_thread[0] != main_thread diff --git a/tests/server/mcpserver/resources/test_function_resources.py b/tests/server/mcpserver/resources/test_function_resources.py index 5f5c216ed..c1ff96061 100644 --- a/tests/server/mcpserver/resources/test_function_resources.py +++ b/tests/server/mcpserver/resources/test_function_resources.py @@ -1,3 +1,7 @@ +import threading + +import anyio +import anyio.from_thread import pytest from pydantic import BaseModel @@ -190,3 +194,51 @@ def get_data() -> str: # pragma: no cover ) assert resource.meta is None + + +@pytest.mark.anyio +async def test_sync_fn_runs_in_worker_thread(): + """Sync resource functions must run in a worker thread, not the event loop.""" + + main_thread = threading.get_ident() + fn_thread: list[int] = [] + + def blocking_fn() -> str: + fn_thread.append(threading.get_ident()) + return "data" + + resource = FunctionResource(uri="resource://test", name="test", fn=blocking_fn) + result = await resource.read() + + assert result == "data" + assert fn_thread[0] != main_thread + + +@pytest.mark.anyio +async def test_sync_fn_does_not_block_event_loop(): + """A blocking sync resource function must not stall the event loop. + + On regression (sync runs inline), anyio.from_thread.run_sync raises + RuntimeError because there is no worker-thread context, failing fast. + """ + handler_entered = anyio.Event() + release = threading.Event() + + def blocking_fn() -> str: + anyio.from_thread.run_sync(handler_entered.set) + release.wait() + return "done" + + resource = FunctionResource(uri="resource://test", name="test", fn=blocking_fn) + result: list[str | bytes] = [] + + async def run() -> None: + result.append(await resource.read()) + + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: + tg.start_soon(run) + await handler_entered.wait() + release.set() + + assert result == ["done"] diff --git a/tests/server/mcpserver/resources/test_resource_manager.py b/tests/server/mcpserver/resources/test_resource_manager.py index 724b57997..f214ea1ca 100644 --- a/tests/server/mcpserver/resources/test_resource_manager.py +++ b/tests/server/mcpserver/resources/test_resource_manager.py @@ -1,177 +1,165 @@ +import logging from pathlib import Path -from tempfile import NamedTemporaryFile import pytest from pydantic import AnyUrl -from mcp.server.mcpserver import Context -from mcp.server.mcpserver.resources import FileResource, FunctionResource, ResourceManager, ResourceTemplate +from mcp.server.mcpserver.context import Context +from mcp.server.mcpserver.resources import FileResource, FunctionResource, ResourceManager -@pytest.fixture -def temp_file(): +@pytest.fixture() +def temp_file(tmp_path: Path): """Create a temporary file for testing. File is automatically cleaned up after the test if it still exists. """ - content = "test content" - with NamedTemporaryFile(mode="w", delete=False) as f: - f.write(content) - path = Path(f.name).resolve() - yield path - try: # pragma: lax no cover - path.unlink() - except FileNotFoundError: # pragma: lax no cover - pass # File was already deleted by the test - - -class TestResourceManager: - """Test ResourceManager functionality.""" - - def test_add_resource(self, temp_file: Path): - """Test adding a resource.""" - manager = ResourceManager() - resource = FileResource( - uri=f"file://{temp_file}", - name="test", - path=temp_file, - ) - added = manager.add_resource(resource) - assert added == resource - assert manager.list_resources() == [resource] - - def test_add_duplicate_resource(self, temp_file: Path): - """Test adding the same resource twice.""" - manager = ResourceManager() - resource = FileResource( - uri=f"file://{temp_file}", - name="test", - path=temp_file, - ) - first = manager.add_resource(resource) - second = manager.add_resource(resource) - assert first == second - assert manager.list_resources() == [resource] - - def test_warn_on_duplicate_resources(self, temp_file: Path, caplog: pytest.LogCaptureFixture): - """Test warning on duplicate resources.""" - manager = ResourceManager() - resource = FileResource( - uri=f"file://{temp_file}", - name="test", - path=temp_file, - ) - manager.add_resource(resource) - manager.add_resource(resource) - assert "Resource already exists" in caplog.text - - def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog: pytest.LogCaptureFixture): - """Test disabling warning on duplicate resources.""" - manager = ResourceManager(warn_on_duplicate_resources=False) - resource = FileResource( - uri=f"file://{temp_file}", - name="test", - path=temp_file, - ) - manager.add_resource(resource) - manager.add_resource(resource) - assert "Resource already exists" not in caplog.text - - @pytest.mark.anyio - async def test_get_resource(self, temp_file: Path): - """Test getting a resource by URI.""" - manager = ResourceManager() - resource = FileResource( - uri=f"file://{temp_file}", - name="test", - path=temp_file, - ) - manager.add_resource(resource) - retrieved = await manager.get_resource(resource.uri, Context()) - assert retrieved == resource - - @pytest.mark.anyio - async def test_get_resource_from_template(self): - """Test getting a resource through a template.""" - manager = ResourceManager() - - def greet(name: str) -> str: - return f"Hello, {name}!" - - template = ResourceTemplate.from_function( - fn=greet, - uri_template="greet://{name}", - name="greeter", - ) - manager._templates[template.uri_template] = template - - resource = await manager.get_resource(AnyUrl("greet://world"), Context()) - assert isinstance(resource, FunctionResource) - content = await resource.read() - assert content == "Hello, world!" - - @pytest.mark.anyio - async def test_get_unknown_resource(self): - """Test getting a non-existent resource.""" - manager = ResourceManager() - with pytest.raises(ValueError, match="Unknown resource"): - await manager.get_resource(AnyUrl("unknown://test"), Context()) - - def test_list_resources(self, temp_file: Path): - """Test listing all resources.""" - manager = ResourceManager() - resource1 = FileResource( - uri=f"file://{temp_file}", - name="test1", - path=temp_file, - ) - resource2 = FileResource( - uri=f"file://{temp_file}2", - name="test2", - path=temp_file, - ) - manager.add_resource(resource1) - manager.add_resource(resource2) - 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 + tmp_file = tmp_path / "file" + tmp_file.touch() + yield tmp_file + + +def test_init_with_resources(temp_file: Path, caplog: pytest.LogCaptureFixture): + resource = FileResource(uri=f"file://{temp_file}", name="test", path=temp_file) + manager = ResourceManager(resources=[resource]) + assert manager.list_resources() == [resource] + + duplicate_resource = FileResource(uri=f"file://{temp_file}", name="duplicate", path=temp_file) + + with caplog.at_level(logging.WARNING): + manager = ResourceManager(True, resources=[resource, duplicate_resource]) + + assert "Resource already exists" in caplog.text + assert manager.list_resources() == [resource] + + +def test_add_resource(temp_file: Path): + """Test adding a resource.""" + manager = ResourceManager() + resource = FileResource(uri=f"file://{temp_file}", name="test", path=temp_file) + added = manager.add_resource(resource) + assert added == resource + assert manager.list_resources() == [resource] + + +def test_add_duplicate_resource(temp_file: Path): + """Test adding the same resource twice.""" + manager = ResourceManager() + resource = FileResource(uri=f"file://{temp_file}", name="test", path=temp_file) + first = manager.add_resource(resource) + second = manager.add_resource(resource) + assert first == second + assert manager.list_resources() == [resource] + + +def test_warn_on_duplicate_resources(temp_file: Path, caplog: pytest.LogCaptureFixture): + """Test warning on duplicate resources.""" + manager = ResourceManager() + resource = FileResource(uri=f"file://{temp_file}", name="test", path=temp_file) + manager.add_resource(resource) + manager.add_resource(resource) + assert "Resource already exists" in caplog.text + + +def test_disable_warn_on_duplicate_resources(temp_file: Path, caplog: pytest.LogCaptureFixture): + """Test disabling warning on duplicate resources.""" + manager = ResourceManager(warn_on_duplicate_resources=False) + resource = FileResource(uri=f"file://{temp_file}", name="test", path=temp_file) + manager.add_resource(resource) + manager.add_resource(resource) + assert "Resource already exists" not in caplog.text + + +@pytest.mark.anyio +async def test_get_resource(temp_file: Path): + """Test getting a resource by URI.""" + manager = ResourceManager() + resource = FileResource(uri=f"file://{temp_file}", name="test", path=temp_file) + manager.add_resource(resource) + retrieved = await manager.get_resource(resource.uri, Context()) + assert retrieved == resource + + +@pytest.mark.anyio +async def test_get_resource_from_template(): + """Test getting a resource through a template.""" + manager = ResourceManager() + + def greet(name: str) -> str: + return f"Hello, {name}!" + + manager.add_template(fn=greet, uri_template="greet://{name}", name="greeter") + + resource = await manager.get_resource(AnyUrl("greet://world"), Context()) + assert isinstance(resource, FunctionResource) + content = await resource.read() + assert content == "Hello, world!" + + +@pytest.mark.anyio +async def test_get_unknown_resource(): + """Test getting a non-existent resource.""" + manager = ResourceManager() + with pytest.raises(ValueError, match="Unknown resource"): + await manager.get_resource(AnyUrl("unknown://test"), Context()) + + +def test_list_resources(temp_file: Path): + """Test listing all resources.""" + manager = ResourceManager() + resource1 = FileResource(uri=f"file://{temp_file}", name="test1", path=temp_file) + resource2 = FileResource(uri=f"file://{temp_file}2", name="test2", path=temp_file) + + manager.add_resource(resource1) + manager.add_resource(resource2) + + resources = manager.list_resources() + assert len(resources) == 2 + assert resources == [resource1, resource2] + + +def get_item(id: str) -> str: ... + + +def test_add_template_with_metadata(): + """Test that ResourceManager.add_template() accepts and passes meta parameter.""" + manager = ResourceManager() + 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(): + """Test that ResourceManager.add_template() works without meta parameter.""" + manager = ResourceManager() + template = manager.add_template(fn=get_item, uri_template="resource://items/{id}") + assert template.meta is None + + +def test_add_duplicate_template(): + """Test adding the same template twice returns the existing one.""" + manager = ResourceManager() + first = manager.add_template(fn=get_item, uri_template="resource://items/{id}") + second = manager.add_template(fn=get_item, uri_template="resource://items/{id}") + assert first is second + assert len(manager.list_templates()) == 1 + + +def test_warn_on_duplicate_template(caplog: pytest.LogCaptureFixture): + """Test warning on duplicate template.""" + manager = ResourceManager() + manager.add_template(fn=get_item, uri_template="resource://items/{id}") + manager.add_template(fn=get_item, uri_template="resource://items/{id}") + assert "Resource template already exists" in caplog.text + + +def test_disable_warn_on_duplicate_template(caplog: pytest.LogCaptureFixture): + """Test disabling warning on duplicate template.""" + manager = ResourceManager(warn_on_duplicate_resources=False) + manager.add_template(fn=get_item, uri_template="resource://items/{id}") + manager.add_template(fn=get_item, uri_template="resource://items/{id}") + assert "Resource template already exists" not in caplog.text diff --git a/tests/server/mcpserver/resources/test_resource_template.py b/tests/server/mcpserver/resources/test_resource_template.py index 640cfe803..2a7ba8d50 100644 --- a/tests/server/mcpserver/resources/test_resource_template.py +++ b/tests/server/mcpserver/resources/test_resource_template.py @@ -1,4 +1,5 @@ import json +import threading from typing import Any import pytest @@ -310,3 +311,22 @@ def get_item(item_id: str) -> str: assert resource.meta == metadata assert resource.meta["category"] == "inventory" assert resource.meta["cacheable"] is True + + +@pytest.mark.anyio +async def test_sync_fn_runs_in_worker_thread(): + """Sync template functions must run in a worker thread, not the event loop.""" + + main_thread = threading.get_ident() + fn_thread: list[int] = [] + + def blocking_fn(name: str) -> str: + fn_thread.append(threading.get_ident()) + return f"hello {name}" + + template = ResourceTemplate.from_function(fn=blocking_fn, uri_template="test://{name}") + resource = await template.create_resource("test://world", {"name": "world"}, Context()) + + assert isinstance(resource, FunctionResource) + assert await resource.read() == "hello world" + assert fn_thread[0] != main_thread diff --git a/tests/server/mcpserver/test_multi_tenancy_e2e.py b/tests/server/mcpserver/test_multi_tenancy_e2e.py new file mode 100644 index 000000000..0c711d535 --- /dev/null +++ b/tests/server/mcpserver/test_multi_tenancy_e2e.py @@ -0,0 +1,385 @@ +"""End-to-end tests for multi-tenant isolation. + +These tests exercise the full tenant isolation stack using the in-memory +transport and the high-level ``Client`` class. They verify that: + +1. Tools, resources, and prompts registered under one tenant are invisible + to other tenants and to the global (None) scope. +2. ``Context.tenant_id`` is correctly populated inside tool handlers. +3. Backward compatibility is preserved — everything works without tenant_id. +""" + +from __future__ import annotations + +import contextvars + +import anyio +import pytest + +from mcp import Client +from mcp.client.session import ClientSession +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.context import Context +from mcp.server.mcpserver.prompts.base import Prompt +from mcp.server.mcpserver.resources.types import FunctionResource +from mcp.shared._context import tenant_id_var +from mcp.shared.memory import create_client_server_memory_streams +from mcp.types import TextContent + +pytestmark = pytest.mark.anyio + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _build_multi_tenant_server() -> MCPServer: + """Build an MCPServer with tenant-scoped tools, resources, and prompts.""" + server = MCPServer("multi-tenant-test") + + # Tenant-A tools / resources / prompts + def tool_a(x: int) -> str: + return f"tenant-a:{x}" + + server.add_tool(tool_a, name="compute", tenant_id="tenant-a") + server.add_resource( + FunctionResource(uri="data://info", name="info-a", fn=lambda: "secret-a"), + tenant_id="tenant-a", + ) + + async def prompt_a() -> str: + return "Hello from tenant-a" + + server.add_prompt(Prompt.from_function(prompt_a, name="greet"), tenant_id="tenant-a") + + # Tenant-B tools / resources / prompts (same names, different data) + def tool_b(x: int) -> str: # pragma: no cover — registered for isolation, never called + return f"tenant-b:{x}" + + server.add_tool(tool_b, name="compute", tenant_id="tenant-b") + server.add_resource( + FunctionResource(uri="data://info", name="info-b", fn=lambda: "secret-b"), + tenant_id="tenant-b", + ) + + async def prompt_b() -> str: + return "Hello from tenant-b" + + server.add_prompt(Prompt.from_function(prompt_b, name="greet"), tenant_id="tenant-b") + + return server + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +async def test_tenant_a_sees_only_own_tools(): + """Tenant-A's client lists only tenant-A's tools.""" + server = _build_multi_tenant_server() + actual = server._lowlevel_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 + + async with anyio.create_task_group() as tg: + + async def run_server() -> None: + token = tenant_id_var.set("tenant-a") + try: + await actual.run( + server_read, server_write, actual.create_initialization_options(), raise_exceptions=True + ) + finally: + tenant_id_var.reset(token) + + tg.start_soon(run_server) + + async with ClientSession(client_read, client_write) as session: + await session.initialize() + tools = await session.list_tools() + assert len(tools.tools) == 1 + assert tools.tools[0].name == "compute" + + tg.cancel_scope.cancel() # pragma: lax no cover + + +async def test_tenant_b_sees_only_own_tools(): + """Tenant-B's client lists only tenant-B's tools.""" + server = _build_multi_tenant_server() + actual = server._lowlevel_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 + + async with anyio.create_task_group() as tg: + + async def run_server() -> None: + token = tenant_id_var.set("tenant-b") + try: + await actual.run( + server_read, server_write, actual.create_initialization_options(), raise_exceptions=True + ) + finally: + tenant_id_var.reset(token) + + tg.start_soon(run_server) + + async with ClientSession(client_read, client_write) as session: + await session.initialize() + tools = await session.list_tools() + assert len(tools.tools) == 1 + assert tools.tools[0].name == "compute" + + tg.cancel_scope.cancel() # pragma: lax no cover + + +async def test_global_scope_sees_nothing_when_all_tenant_scoped(): + """With no tenant context, no tools/resources/prompts are visible.""" + server = _build_multi_tenant_server() + actual = server._lowlevel_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 + + async with anyio.create_task_group() as tg: + tg.start_soon( + lambda: actual.run( + server_read, server_write, actual.create_initialization_options(), raise_exceptions=True + ) + ) + + async with ClientSession(client_read, client_write) as session: + await session.initialize() + + tools = await session.list_tools() + assert len(tools.tools) == 0 + + resources = await session.list_resources() + assert len(resources.resources) == 0 + + prompts = await session.list_prompts() + assert len(prompts.prompts) == 0 + + tg.cancel_scope.cancel() # pragma: lax no cover + + +async def test_tenant_tool_returns_correct_result(): + """Calling a tenant-scoped tool returns the correct tenant's result.""" + server = _build_multi_tenant_server() + actual = server._lowlevel_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 + + async with anyio.create_task_group() as tg: + + async def run_server() -> None: + token = tenant_id_var.set("tenant-a") + try: + await actual.run( + server_read, server_write, actual.create_initialization_options(), raise_exceptions=True + ) + finally: + tenant_id_var.reset(token) + + tg.start_soon(run_server) + + async with ClientSession(client_read, client_write) as session: + await session.initialize() + result = await session.call_tool("compute", {"x": 42}) + texts = [c.text for c in result.content if isinstance(c, TextContent)] + assert any("tenant-a:42" in t for t in texts) + + tg.cancel_scope.cancel() # pragma: lax no cover + + +async def test_tenant_resource_isolation(): + """Tenant-A can read its resource; tenant-B reads a different value.""" + server = _build_multi_tenant_server() + actual = server._lowlevel_server # type: ignore[reportPrivateUsage] + + for tenant, expected_name in [("tenant-a", "info-a"), ("tenant-b", "info-b")]: + 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: + + async def run_server(tid: str = tenant) -> None: + token = tenant_id_var.set(tid) + try: + await actual.run( + server_read, server_write, actual.create_initialization_options(), raise_exceptions=True + ) + finally: + tenant_id_var.reset(token) + + tg.start_soon(run_server) + + async with ClientSession(client_read, client_write) as session: + await session.initialize() + resources = await session.list_resources() + assert len(resources.resources) == 1 + assert resources.resources[0].name == expected_name + + tg.cancel_scope.cancel() # pragma: lax no cover + + +async def test_tenant_prompt_isolation(): + """Each tenant sees only its own prompts.""" + server = _build_multi_tenant_server() + actual = server._lowlevel_server # type: ignore[reportPrivateUsage] + + for tenant in ["tenant-a", "tenant-b"]: + 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: + + async def run_server(tid: str = tenant) -> None: + token = tenant_id_var.set(tid) + try: + await actual.run( + server_read, server_write, actual.create_initialization_options(), raise_exceptions=True + ) + finally: + tenant_id_var.reset(token) + + tg.start_soon(run_server) + + async with ClientSession(client_read, client_write) as session: + await session.initialize() + prompts = await session.list_prompts() + assert len(prompts.prompts) == 1 + assert prompts.prompts[0].name == "greet" + + result = await session.get_prompt("greet") + text = result.messages[0].content.text # type: ignore[union-attr] + assert tenant in text + + tg.cancel_scope.cancel() # pragma: lax no cover + + +async def test_context_tenant_id_available_in_tool(): + """The ``Context.tenant_id`` property is populated inside a tool handler.""" + captured_tenant: list[str | None] = [] + + server = MCPServer("ctx-test") + + def check_tenant(ctx: Context) -> str: + captured_tenant.append(ctx.tenant_id) + return "ok" + + # Register under the tenant scope that will be active during the test + server.add_tool(check_tenant, name="check_tenant", tenant_id="my-tenant") + actual = server._lowlevel_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 + + async with anyio.create_task_group() as tg: + + async def run_server() -> None: + token = tenant_id_var.set("my-tenant") + try: + await actual.run( + server_read, server_write, actual.create_initialization_options(), raise_exceptions=True + ) + finally: + tenant_id_var.reset(token) + + tg.start_soon(run_server) + + async with ClientSession(client_read, client_write) as session: + await session.initialize() + await session.call_tool("check_tenant", {}) + + tg.cancel_scope.cancel() # pragma: lax no cover + + assert captured_tenant == ["my-tenant"] + + +async def test_backward_compat_no_tenant(): + """Without tenant_id set, tools/resources/prompts in global scope work normally.""" + server = MCPServer("compat-test") + + @server.tool() + def hello(name: str) -> str: + return f"Hi {name}" + + @server.resource("test://data") + def data() -> str: # pragma: no cover — registered for listing, not read + return "some data" + + @server.prompt() + def ask() -> str: # pragma: no cover — registered for listing, not called + return "Please answer" + + async with Client(server) as client: + tools = await client.list_tools() + assert len(tools.tools) == 1 + + result = await client.call_tool("hello", {"name": "World"}) + assert any("Hi World" in c.text for c in result.content if isinstance(c, TextContent)) + + resources = await client.list_resources() + assert len(resources.resources) == 1 + + prompts = await client.list_prompts() + assert len(prompts.prompts) == 1 + + +_CLIENT_VAR: contextvars.ContextVar[str] = contextvars.ContextVar("_test_client_var") + + +async def test_sender_context_and_tenant_id_coexist(): + """Both client-side contextvars and server-side tenant_id are visible in handlers.""" + captured: dict[str, str | None] = {} + + server = MCPServer("merge-test") + + def probe(ctx: Context) -> str: + captured["tenant_id"] = ctx.tenant_id + captured["client_var"] = _CLIENT_VAR.get(None) + return "ok" + + server.add_tool(probe, name="probe", tenant_id="merged-tenant") + actual = server._lowlevel_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 + + async with anyio.create_task_group() as tg: + + async def run_server() -> None: + token = tenant_id_var.set("merged-tenant") + try: + await actual.run( + server_read, server_write, actual.create_initialization_options(), raise_exceptions=True + ) + finally: + tenant_id_var.reset(token) + + tg.start_soon(run_server) + + # Set a client-side contextvar before sending the request + _CLIENT_VAR.set("hello-from-client") + + async with ClientSession(client_read, client_write) as session: + await session.initialize() + await session.call_tool("probe", {}) + + tg.cancel_scope.cancel() # pragma: lax no cover + + assert captured["tenant_id"] == "merged-tenant" + assert captured["client_var"] == "hello-from-client" diff --git a/tests/server/mcpserver/test_multi_tenancy_managers.py b/tests/server/mcpserver/test_multi_tenancy_managers.py new file mode 100644 index 000000000..16e8ffe2e --- /dev/null +++ b/tests/server/mcpserver/test_multi_tenancy_managers.py @@ -0,0 +1,373 @@ +"""Tests for tenant-scoped storage in ToolManager, ResourceManager, and PromptManager.""" + +import pytest + +from mcp.server.mcpserver.exceptions import ToolError +from mcp.server.mcpserver.prompts.base import Prompt +from mcp.server.mcpserver.prompts.manager import PromptManager +from mcp.server.mcpserver.resources.resource_manager import ResourceManager +from mcp.server.mcpserver.resources.types import FunctionResource +from mcp.server.mcpserver.tools import ToolManager +from tests.server.mcpserver.conftest import MakeContext + +# --- ToolManager --- + + +def test_add_tool_with_tenant_id(): + """Tools added under different tenants are isolated.""" + manager = ToolManager() + + def tool_a() -> str: # pragma: no cover + return "a" + + def tool_b() -> str: # pragma: no cover + return "b" + + manager.add_tool(tool_a, name="shared_name", tenant_id="tenant-a") + manager.add_tool(tool_b, name="shared_name", tenant_id="tenant-b") + + assert manager.get_tool("shared_name", tenant_id="tenant-a") is not None + assert manager.get_tool("shared_name", tenant_id="tenant-b") is not None + # Different tool objects despite same name + assert manager.get_tool("shared_name", tenant_id="tenant-a") is not manager.get_tool( + "shared_name", tenant_id="tenant-b" + ) + + +def test_list_tools_filtered_by_tenant(): + """list_tools only returns tools for the requested tenant.""" + manager = ToolManager() + + def fa() -> str: # pragma: no cover + return "a" + + def fb() -> str: # pragma: no cover + return "b" + + def fc() -> str: # pragma: no cover + return "c" + + manager.add_tool(fa, tenant_id="tenant-a") + manager.add_tool(fb, tenant_id="tenant-b") + manager.add_tool(fc) # global (None tenant) + + assert len(manager.list_tools(tenant_id="tenant-a")) == 1 + assert len(manager.list_tools(tenant_id="tenant-b")) == 1 + assert len(manager.list_tools()) == 1 # global only + + +def test_get_tool_wrong_tenant_returns_none(): + """A tool registered under tenant-a is not visible to tenant-b.""" + manager = ToolManager() + + def my_tool() -> str: # pragma: no cover + return "x" + + manager.add_tool(my_tool, tenant_id="tenant-a") + + assert manager.get_tool("my_tool", tenant_id="tenant-a") is not None + assert manager.get_tool("my_tool", tenant_id="tenant-b") is None + assert manager.get_tool("my_tool") is None # global scope + + +def test_remove_tool_with_tenant(): + """remove_tool respects tenant scope.""" + manager = ToolManager() + + def my_tool() -> str: # pragma: no cover + return "x" + + manager.add_tool(my_tool, tenant_id="tenant-a") + manager.add_tool(my_tool, name="my_tool", tenant_id="tenant-b") + + manager.remove_tool("my_tool", tenant_id="tenant-a") + + assert manager.get_tool("my_tool", tenant_id="tenant-a") is None + assert manager.get_tool("my_tool", tenant_id="tenant-b") is not None + # Empty tenant scope is cleaned up + assert "tenant-a" not in manager._tools + + +def test_remove_tool_wrong_tenant_raises(): + """Removing a tool under the wrong tenant raises ToolError.""" + manager = ToolManager() + + def my_tool() -> str: # pragma: no cover + return "x" + + manager.add_tool(my_tool, tenant_id="tenant-a") + + with pytest.raises(ToolError): + manager.remove_tool("my_tool", tenant_id="tenant-b") + + +@pytest.mark.anyio +async def test_call_tool_with_tenant(make_context: MakeContext): + """call_tool respects tenant scope.""" + manager = ToolManager() + + def tool_a() -> str: + return "result-a" + + def tool_b() -> str: + return "result-b" + + manager.add_tool(tool_a, name="do_work", tenant_id="tenant-a") + manager.add_tool(tool_b, name="do_work", tenant_id="tenant-b") + + result_a = await manager.call_tool("do_work", {}, make_context(), tenant_id="tenant-a") + result_b = await manager.call_tool("do_work", {}, make_context(), tenant_id="tenant-b") + + assert result_a == "result-a" + assert result_b == "result-b" + + +@pytest.mark.anyio +async def test_call_tool_wrong_tenant_raises(make_context: MakeContext): + """Calling a tool under the wrong tenant raises ToolError.""" + manager = ToolManager() + + def my_tool() -> str: # pragma: no cover + return "x" + + manager.add_tool(my_tool, tenant_id="tenant-a") + + with pytest.raises(ToolError): + await manager.call_tool("my_tool", {}, make_context(), tenant_id="tenant-b") + + +# --- ResourceManager --- + + +def _make_resource(uri: str, name: str) -> FunctionResource: + """Helper to create a concrete resource.""" + return FunctionResource(uri=uri, name=name, fn=lambda: name) + + +def test_add_resource_with_tenant(): + """Resources added under different tenants are isolated.""" + manager = ResourceManager() + + resource_a = _make_resource("file:///data", "data-a") + resource_b = _make_resource("file:///data", "data-b") + + added_a = manager.add_resource(resource_a, tenant_id="tenant-a") + added_b = manager.add_resource(resource_b, tenant_id="tenant-b") + + assert added_a.name == "data-a" + assert added_b.name == "data-b" + + +def test_list_resources_filtered_by_tenant(): + """list_resources only returns resources for the requested tenant.""" + manager = ResourceManager() + + manager.add_resource(_make_resource("file:///a", "a"), tenant_id="tenant-a") + manager.add_resource(_make_resource("file:///b", "b"), tenant_id="tenant-b") + manager.add_resource(_make_resource("file:///g", "global")) + + assert len(manager.list_resources(tenant_id="tenant-a")) == 1 + assert len(manager.list_resources(tenant_id="tenant-b")) == 1 + assert len(manager.list_resources()) == 1 + + +def test_add_template_with_tenant(): + """Templates added under different tenants are isolated.""" + manager = ResourceManager() + + def greet_a(name: str) -> str: # pragma: no cover + return f"Hello from A, {name}!" + + def greet_b(name: str) -> str: # pragma: no cover + return f"Hello from B, {name}!" + + manager.add_template(greet_a, uri_template="greet://{name}", tenant_id="tenant-a") + manager.add_template(greet_b, uri_template="greet://{name}", tenant_id="tenant-b") + + assert len(manager.list_templates(tenant_id="tenant-a")) == 1 + assert len(manager.list_templates(tenant_id="tenant-b")) == 1 + assert len(manager.list_templates()) == 0 # no global templates + + +@pytest.mark.anyio +async def test_get_resource_respects_tenant(make_context: MakeContext): + """get_resource only finds resources in the correct tenant scope.""" + manager = ResourceManager() + + resource = _make_resource("file:///secret", "secret") + manager.add_resource(resource, tenant_id="tenant-a") + + # Tenant A can access + found = await manager.get_resource("file:///secret", make_context(), tenant_id="tenant-a") + assert found.name == "secret" + + # Tenant B cannot + with pytest.raises(ValueError, match="Unknown resource"): + await manager.get_resource("file:///secret", make_context(), tenant_id="tenant-b") + + # Global scope cannot + with pytest.raises(ValueError, match="Unknown resource"): + await manager.get_resource("file:///secret", make_context()) + + +@pytest.mark.anyio +async def test_get_resource_from_template_respects_tenant(make_context: MakeContext): + """Template-based resource creation respects tenant scope.""" + manager = ResourceManager() + + def greet(name: str) -> str: + return f"Hello, {name}!" + + manager.add_template(greet, uri_template="greet://{name}", tenant_id="tenant-a") + + # Tenant A can resolve + resource = await manager.get_resource("greet://world", make_context(), tenant_id="tenant-a") + assert isinstance(resource, FunctionResource) + content = await resource.read() + assert content == "Hello, world!" + + # Tenant B cannot + with pytest.raises(ValueError, match="Unknown resource"): + await manager.get_resource("greet://world", make_context(), tenant_id="tenant-b") + + +def test_remove_resource_with_tenant(): + """remove_resource respects tenant scope.""" + manager = ResourceManager() + + manager.add_resource(_make_resource("file:///data", "data"), tenant_id="tenant-a") + manager.add_resource(_make_resource("file:///data", "data"), tenant_id="tenant-b") + + manager.remove_resource("file:///data", tenant_id="tenant-a") + + assert len(manager.list_resources(tenant_id="tenant-a")) == 0 + assert len(manager.list_resources(tenant_id="tenant-b")) == 1 + # Empty tenant scope is cleaned up + assert "tenant-a" not in manager._resources + + +def test_remove_resource_partial_tenant_scope(): + """Removing one resource leaves the tenant scope intact when others remain.""" + manager = ResourceManager() + + manager.add_resource(_make_resource("file:///a", "a"), tenant_id="tenant-a") + manager.add_resource(_make_resource("file:///b", "b"), tenant_id="tenant-a") + + manager.remove_resource("file:///a", tenant_id="tenant-a") + + assert len(manager.list_resources(tenant_id="tenant-a")) == 1 + assert "tenant-a" in manager._resources + + +def test_remove_resource_wrong_tenant_raises(): + """Removing a resource under the wrong tenant raises ValueError.""" + manager = ResourceManager() + manager.add_resource(_make_resource("file:///data", "data"), tenant_id="tenant-a") + + with pytest.raises(ValueError, match="Unknown resource"): + manager.remove_resource("file:///data", tenant_id="tenant-b") + + +# --- PromptManager --- + + +def _make_prompt(name: str, text: str) -> Prompt: + """Helper to create a simple prompt.""" + + async def fn() -> str: # pragma: no cover + return text + + return Prompt.from_function(fn, name=name) + + +def test_add_prompt_with_tenant(): + """Prompts added under different tenants are isolated.""" + manager = PromptManager() + + prompt_a = _make_prompt("greet", "Hello from A") + prompt_b = _make_prompt("greet", "Hello from B") + + manager.add_prompt(prompt_a, tenant_id="tenant-a") + manager.add_prompt(prompt_b, tenant_id="tenant-b") + + assert manager.get_prompt("greet", tenant_id="tenant-a") is prompt_a + assert manager.get_prompt("greet", tenant_id="tenant-b") is prompt_b + + +def test_list_prompts_filtered_by_tenant(): + """list_prompts only returns prompts for the requested tenant.""" + manager = PromptManager() + + manager.add_prompt(_make_prompt("a", "A"), tenant_id="tenant-a") + manager.add_prompt(_make_prompt("b", "B"), tenant_id="tenant-b") + manager.add_prompt(_make_prompt("g", "Global")) + + assert len(manager.list_prompts(tenant_id="tenant-a")) == 1 + assert len(manager.list_prompts(tenant_id="tenant-b")) == 1 + assert len(manager.list_prompts()) == 1 + + +def test_get_prompt_wrong_tenant_returns_none(): + """A prompt registered under tenant-a is not visible to tenant-b.""" + manager = PromptManager() + manager.add_prompt(_make_prompt("secret", "x"), tenant_id="tenant-a") + + assert manager.get_prompt("secret", tenant_id="tenant-a") is not None + assert manager.get_prompt("secret", tenant_id="tenant-b") is None + assert manager.get_prompt("secret") is None + + +@pytest.mark.anyio +async def test_render_prompt_respects_tenant(make_context: MakeContext): + """render_prompt only finds prompts in the correct tenant scope.""" + manager = PromptManager() + + async def greet() -> str: + return "Hello from tenant-a" + + manager.add_prompt(Prompt.from_function(greet, name="greet"), tenant_id="tenant-a") + + # Tenant A can render + messages = await manager.render_prompt("greet", None, make_context(), tenant_id="tenant-a") + assert len(messages) > 0 + + # Tenant B cannot + with pytest.raises(ValueError, match="Unknown prompt"): + await manager.render_prompt("greet", None, make_context(), tenant_id="tenant-b") + + +def test_remove_prompt_with_tenant(): + """remove_prompt respects tenant scope.""" + manager = PromptManager() + + manager.add_prompt(_make_prompt("greet", "A"), tenant_id="tenant-a") + manager.add_prompt(_make_prompt("greet", "B"), tenant_id="tenant-b") + + manager.remove_prompt("greet", tenant_id="tenant-a") + + assert manager.get_prompt("greet", tenant_id="tenant-a") is None + assert manager.get_prompt("greet", tenant_id="tenant-b") is not None + # Empty tenant scope is cleaned up + assert "tenant-a" not in manager._prompts + + +def test_remove_prompt_partial_tenant_scope(): + """Removing one prompt leaves the tenant scope intact when others remain.""" + manager = PromptManager() + + manager.add_prompt(_make_prompt("greet", "A"), tenant_id="tenant-a") + manager.add_prompt(_make_prompt("farewell", "B"), tenant_id="tenant-a") + + manager.remove_prompt("greet", tenant_id="tenant-a") + + assert len(manager.list_prompts(tenant_id="tenant-a")) == 1 + assert "tenant-a" in manager._prompts + + +def test_remove_prompt_wrong_tenant_raises(): + """Removing a prompt under the wrong tenant raises ValueError.""" + manager = PromptManager() + manager.add_prompt(_make_prompt("greet", "A"), tenant_id="tenant-a") + + with pytest.raises(ValueError, match="Unknown prompt"): + manager.remove_prompt("greet", tenant_id="tenant-b") diff --git a/tests/server/mcpserver/test_multi_tenancy_oauth_e2e.py b/tests/server/mcpserver/test_multi_tenancy_oauth_e2e.py new file mode 100644 index 000000000..46ef425de --- /dev/null +++ b/tests/server/mcpserver/test_multi_tenancy_oauth_e2e.py @@ -0,0 +1,422 @@ +"""End-to-end test for multi-tenant isolation through the OAuth + HTTP stack. + +This test exercises the full production path that a real deployment would use: + + 1. Client sends HTTP request with ``Authorization: Bearer `` + 2. ``AuthContextMiddleware`` validates the token via ``TokenVerifier``, + extracts ``AccessToken.tenant_id``, and sets the ``tenant_id_var`` + contextvar for the duration of the request. + 3. ``StreamableHTTPSessionManager`` binds new sessions to the current + tenant and rejects cross-tenant session access. + 4. The low-level ``Server._handle_request`` reads ``tenant_id_var`` and + populates ``ServerRequestContext.tenant_id``. + 5. ``MCPServer`` handlers (e.g. ``_handle_list_tools``) pass + ``ctx.tenant_id`` to the appropriate manager, which returns only + the items registered under that tenant. + 6. The client sees only its own tenant's tools/resources/prompts. + +Unlike the in-memory E2E tests in ``test_multi_tenancy_e2e.py`` that set +``tenant_id_var`` manually, this test uses a real Starlette app with auth +middleware and HTTP transport to verify the full integration — proving that +tenant_id flows correctly from the OAuth token all the way through to the +handler response. + +Key complexity notes: + - We use ``StubTokenVerifier`` instead of a full OAuth provider because + the MCP auth stack allows plugging in a custom ``TokenVerifier``. This + lets us skip the OAuth authorization code flow while still exercising + the real ``AuthContextMiddleware`` → ``tenant_id_var`` path. + - ``httpx.ASGITransport`` does NOT send ASGI lifespan events, so + Starlette's lifespan (which starts ``StreamableHTTPSessionManager.run()``) + never fires. We work around this with ``_start_lifespan()``, which + manually sends the lifespan startup/shutdown events to the ASGI app. +""" + +from __future__ import annotations + +import time +from collections.abc import AsyncIterator, MutableMapping +from contextlib import asynccontextmanager +from typing import Any + +import anyio +import httpx +import pytest +from pydantic import AnyHttpUrl +from starlette.applications import Starlette + +from mcp.client.session import ClientSession +from mcp.client.streamable_http import streamable_http_client +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.server.auth.settings import AuthSettings +from mcp.server.mcpserver.context import Context +from mcp.server.mcpserver.server import MCPServer +from mcp.server.transport_security import TransportSecuritySettings +from mcp.types import TextContent + +pytestmark = pytest.mark.anyio + + +# --------------------------------------------------------------------------- +# Stub token verifier — maps bearer tokens to AccessTokens with tenant_id +# --------------------------------------------------------------------------- + + +class StubTokenVerifier(TokenVerifier): + """Token verifier that recognises hard-coded bearer tokens. + + In production, ``TokenVerifier.verify_token()`` would call an OAuth + introspection endpoint or decode a JWT. Here we simply look up the + token in a pre-built dict, returning the corresponding ``AccessToken`` + (which includes ``tenant_id``). This is the minimal surface needed to + exercise the real auth middleware without a full OAuth server. + """ + + def __init__(self, token_map: dict[str, AccessToken]) -> None: + self._tokens = token_map + + async def verify_token(self, token: str) -> AccessToken | None: + # Returns None for unknown tokens, which the auth middleware + # treats as an authentication failure (HTTP 401). + return self._tokens.get(token) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +@asynccontextmanager +async def _start_lifespan(app: httpx._transports.asgi._ASGIApp) -> AsyncIterator[None]: + """Manually trigger ASGI lifespan startup/shutdown on a Starlette app. + + Why this is needed: + ``httpx.ASGITransport`` sends only HTTP request events — it does NOT + send ASGI lifespan events. However, the Starlette app returned by + ``MCPServer.streamable_http_app()`` has a lifespan handler that starts + ``StreamableHTTPSessionManager.run()``. Without lifespan startup, the + session manager's internal task group is never initialised, and any + HTTP request that tries to create a session will fail with: + RuntimeError: Task group is not initialized. Make sure to use run(). + + How it works: + We call the ASGI app directly with a ``lifespan`` scope and provide + custom ``receive``/``send`` callables that simulate the ASGI server's + lifespan protocol: + 1. Send ``lifespan.startup`` → app initialises (starts session manager) + 2. Wait for ``lifespan.startup.complete`` from the app + 3. Yield control to the test + 4. On cleanup, send ``lifespan.shutdown`` → app tears down + 5. Wait for ``lifespan.shutdown.complete``, then cancel the task group + """ + # Events to coordinate the lifespan protocol handshake + started = anyio.Event() + shutdown = anyio.Event() + startup_complete = anyio.Event() + shutdown_complete = anyio.Event() + + # ASGI lifespan scope — tells the app this is a lifespan connection + scope = {"type": "lifespan", "asgi": {"version": "3.0"}} + + async def receive() -> dict[str, str]: + """Feed lifespan events to the ASGI app. + + Called twice: once for startup (immediately), once for shutdown + (blocks until the test is done and ``shutdown`` is set). + """ + if not started.is_set(): + started.set() + return {"type": "lifespan.startup"} + # Block here until the test finishes and triggers shutdown + await shutdown.wait() + return {"type": "lifespan.shutdown"} + + async def send(message: MutableMapping[str, Any]) -> None: + """Receive acknowledgements from the ASGI app.""" + if message["type"] == "lifespan.startup.complete": + startup_complete.set() + elif message["type"] == "lifespan.shutdown.complete": # pragma: no branch + shutdown_complete.set() + + async with anyio.create_task_group() as tg: + # Run the ASGI app's lifespan handler in the background + tg.start_soon(app, scope, receive, send) + # Wait until the app signals that startup is complete + await startup_complete.wait() + try: + yield + finally: + # Signal the app to shut down and wait for confirmation + shutdown.set() + await shutdown_complete.wait() + tg.cancel_scope.cancel() + + +def _build_tenant_server(verifier: StubTokenVerifier) -> MCPServer: + """Create an MCPServer with auth enabled and tenant-scoped tools. + + The server is configured with: + - ``token_verifier``: Our stub that maps bearer tokens to AccessTokens + - ``auth``: AuthSettings that enable the auth middleware stack + (issuer_url and resource_server_url are fake since we bypass OAuth) + + Tools registered: + - "query" under tenant "alpha" — simulates an analytics tool + - "publish" under tenant "beta" — simulates a publishing tool + - "whoami" under both tenants — reads ctx.tenant_id to prove + the tenant context is correctly propagated to handlers + """ + server = MCPServer( + "tenant-oauth-test", + token_verifier=verifier, + auth=AuthSettings( + issuer_url=AnyHttpUrl("https://auth.example.com"), + resource_server_url=AnyHttpUrl("https://mcp.example.com"), + required_scopes=["read"], + ), + ) + + # Tenant "alpha" tools — only visible to requests with tenant_id="alpha" + def alpha_query(sql: str) -> str: + return f"alpha: {sql}" + + server.add_tool(alpha_query, name="query", tenant_id="alpha") + + # Tenant "beta" tools — only visible to requests with tenant_id="beta" + def beta_publish(title: str) -> str: # pragma: no cover — registered for isolation, never called + return f"beta: {title}" + + server.add_tool(beta_publish, name="publish", tenant_id="beta") + + # "whoami" is registered under BOTH tenants (same function, different + # tenant scopes). This lets us verify that ctx.tenant_id is correctly + # set for each tenant's request independently. + def whoami(ctx: Context) -> str: + return f"tenant={ctx.tenant_id}" + + server.add_tool(whoami, name="whoami", tenant_id="alpha") + server.add_tool(whoami, name="whoami", tenant_id="beta") + + return server + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def token_map() -> dict[str, AccessToken]: + """Map bearer token strings to AccessToken objects with tenant_id. + + This simulates what a real OAuth token introspection would return: + each bearer token resolves to an AccessToken containing the tenant_id + that identifies which tenant the caller belongs to. + """ + now = int(time.time()) + return { + # "token-alpha" authenticates as tenant "alpha" with scope "read" + "token-alpha": AccessToken( + token="token-alpha", + client_id="client-1", + scopes=["read"], + expires_at=now + 3600, + tenant_id="alpha", + ), + # "token-beta" authenticates as tenant "beta" with scope "read" + "token-beta": AccessToken( + token="token-beta", + client_id="client-2", + scopes=["read"], + expires_at=now + 3600, + tenant_id="beta", + ), + } + + +@pytest.fixture +def verifier(token_map: dict[str, AccessToken]) -> StubTokenVerifier: + return StubTokenVerifier(token_map) + + +@pytest.fixture +def tenant_app(verifier: StubTokenVerifier) -> MCPServer: + return _build_tenant_server(verifier) + + +@pytest.fixture +def starlette_app(tenant_app: MCPServer) -> Starlette: + """Build the Starlette ASGI app with DNS rebinding protection disabled. + + Starlette is the ASGI web framework that MCPServer uses under the hood + for HTTP transport. ``MCPServer.streamable_http_app()`` returns a + Starlette ``Application`` wired with: + - Auth middleware (``AuthenticationMiddleware`` + ``AuthContextMiddleware``) + that validates bearer tokens and sets ``tenant_id_var`` + - A ``StreamableHTTPASGIApp`` route that handles MCP JSON-RPC over HTTP + - A lifespan handler that starts/stops ``StreamableHTTPSessionManager`` + - Transport security middleware for DNS rebinding protection + + In tests we use ``httpx.ASGITransport`` to send requests directly to + this ASGI app in-process (no real network). However, ASGITransport + sends the Host header as just "localhost" without a port, while the + default DNS rebinding protection expects "localhost:". We disable + DNS rebinding protection here since it's not relevant to tenant isolation. + """ + return tenant_app.streamable_http_app( + transport_security=TransportSecuritySettings( + enable_dns_rebinding_protection=False, + ), + ) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +async def test_alpha_sees_only_own_tools(starlette_app: Starlette): + """A client authenticating as tenant 'alpha' sees only alpha's tools. + + Verifies the full path: Bearer token-alpha → AuthContextMiddleware + extracts tenant_id="alpha" → ToolManager filters to alpha's tools + → client receives ["query", "whoami"] (not beta's "publish"). + """ + # Start ASGI lifespan to initialise the StreamableHTTPSessionManager + async with _start_lifespan(starlette_app): + # Create an HTTP client that sends requests through ASGITransport + # directly to the Starlette app (no real network involved). + # The Authorization header is included on every request. + http_client = httpx.AsyncClient( + transport=httpx.ASGITransport(app=starlette_app), + headers={"Authorization": "Bearer token-alpha"}, + ) + async with http_client: + # Use the MCP streamable HTTP client to establish a session + async with streamable_http_client( # pragma: no branch + url="http://localhost/mcp", + http_client=http_client, + ) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: # pragma: no branch + await session.initialize() + # List tools — should only see alpha's tools + tools = await session.list_tools() + tool_names = sorted(t.name for t in tools.tools) + assert tool_names == ["query", "whoami"] + + +async def test_beta_sees_only_own_tools(starlette_app: Starlette): + """A client authenticating as tenant 'beta' sees only beta's tools. + + Same structure as the alpha test, but with token-beta. Verifies that + beta sees ["publish", "whoami"] and NOT alpha's "query" tool. + """ + async with _start_lifespan(starlette_app): + http_client = httpx.AsyncClient( + transport=httpx.ASGITransport(app=starlette_app), + headers={"Authorization": "Bearer token-beta"}, + ) + async with http_client: + async with streamable_http_client( # pragma: no branch + url="http://localhost/mcp", + http_client=http_client, + ) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: # pragma: no branch + await session.initialize() + tools = await session.list_tools() + tool_names = sorted(t.name for t in tools.tools) + assert tool_names == ["publish", "whoami"] + + +async def test_alpha_can_call_own_tool(starlette_app: Starlette): + """Tenant alpha can call its own tool and get the correct result. + + Goes beyond list_tools — actually invokes the "query" tool to verify + that the tool execution path also respects tenant scoping. The tool + function returns "alpha: " to confirm the right tool ran. + """ + async with _start_lifespan(starlette_app): + http_client = httpx.AsyncClient( + transport=httpx.ASGITransport(app=starlette_app), + headers={"Authorization": "Bearer token-alpha"}, + ) + async with http_client: + async with streamable_http_client( # pragma: no branch + url="http://localhost/mcp", + http_client=http_client, + ) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: # pragma: no branch + await session.initialize() + result = await session.call_tool("query", {"sql": "SELECT 1"}) + texts = [c.text for c in result.content if isinstance(c, TextContent)] + assert any("alpha: SELECT 1" in t for t in texts) + + +async def test_whoami_returns_correct_tenant(starlette_app: Starlette): + """The whoami tool reports the authenticated tenant identity. + + This is the strongest proof that tenant_id propagates end-to-end: + the tool reads ``ctx.tenant_id`` (set by the low-level server from + ``tenant_id_var``, which was set by ``AuthContextMiddleware`` from + ``AccessToken.tenant_id``). Each tenant gets a different value. + + We test both tenants in a single test to verify isolation within + the same Starlette app instance (shared session manager). + """ + async with _start_lifespan(starlette_app): + # Test both tenants against the same running app + for token, expected_tenant in [("token-alpha", "alpha"), ("token-beta", "beta")]: + http_client = httpx.AsyncClient( + transport=httpx.ASGITransport(app=starlette_app), + headers={"Authorization": f"Bearer {token}"}, + ) + async with http_client: + async with streamable_http_client( # pragma: no branch + url="http://localhost/mcp", + http_client=http_client, + ) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: # pragma: no branch + await session.initialize() + result = await session.call_tool("whoami", {}) + texts = [c.text for c in result.content if isinstance(c, TextContent)] + assert any(f"tenant={expected_tenant}" in t for t in texts) + + +async def test_unauthenticated_request_is_rejected(starlette_app: Starlette): + """A request without a bearer token is rejected by auth middleware. + + Verifies that the auth middleware (enabled by ``AuthSettings`` and + ``TokenVerifier``) returns HTTP 401 when no Authorization header is + present. This is a basic security check — without valid credentials, + no MCP session can be established. + + Unlike the other tests, this one sends a raw HTTP POST instead of + using the MCP client, since the client would fail to initialise + (which is the expected behaviour). + """ + async with _start_lifespan(starlette_app): + # No Authorization header — should be rejected + http_client = httpx.AsyncClient( + transport=httpx.ASGITransport(app=starlette_app), + ) + async with http_client: + # Send a raw JSON-RPC initialize request without auth + response = await http_client.post( + "http://localhost/mcp", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "0.1"}, + }, + }, + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + ) + # Auth middleware should reject with 401 Unauthorized + assert response.status_code == 401 diff --git a/tests/server/mcpserver/test_multi_tenancy_server.py b/tests/server/mcpserver/test_multi_tenancy_server.py new file mode 100644 index 000000000..590729750 --- /dev/null +++ b/tests/server/mcpserver/test_multi_tenancy_server.py @@ -0,0 +1,370 @@ +"""Tests for tenant-scoped MCPServer server integration. + +Validates that tenant_id flows from MCPServer public methods down to the +underlying managers, and that Context exposes tenant_id correctly. +""" + +import pytest + +from mcp.server.experimental.request_context import Experimental +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.context import Context +from mcp.server.mcpserver.prompts.base import Prompt +from mcp.server.mcpserver.resources.types import FunctionResource + +pytestmark = pytest.mark.anyio + + +# --- Context.tenant_id property --- + + +def test_context_tenant_id_without_request_context(): + """Context.tenant_id returns None when no request context is set.""" + ctx = Context() + assert ctx.tenant_id is None + + +def test_context_tenant_id_with_request_context(): + """Context.tenant_id returns the tenant_id from the request context.""" + from mcp.server.context import ServerRequestContext + + # Create a minimal ServerRequestContext with tenant_id + # We need real streams for ServerSession but won't use them + + rc = ServerRequestContext( + session=None, # type: ignore[arg-type] + lifespan_context=None, + experimental=Experimental(), + tenant_id="tenant-x", + ) + ctx = Context(request_context=rc) + assert ctx.tenant_id == "tenant-x" + + +def test_context_tenant_id_none_in_request_context(): + """Context.tenant_id returns None when request context has no tenant_id.""" + from mcp.server.context import ServerRequestContext + + rc = ServerRequestContext( + session=None, # type: ignore[arg-type] + lifespan_context=None, + experimental=Experimental(), + ) + ctx = Context(request_context=rc) + assert ctx.tenant_id is None + + +# --- MCPServer public methods with tenant_id --- + + +async def test_list_tools_with_tenant_id(): + """list_tools filters by tenant_id.""" + server = MCPServer("test") + + def tool_a() -> str: # pragma: no cover + return "a" + + def tool_b() -> str: # pragma: no cover + return "b" + + server.add_tool(tool_a, name="shared", tenant_id="tenant-a") + server.add_tool(tool_b, name="shared", tenant_id="tenant-b") + + tools_a = await server.list_tools(tenant_id="tenant-a") + tools_b = await server.list_tools(tenant_id="tenant-b") + tools_global = await server.list_tools() + + assert len(tools_a) == 1 + assert tools_a[0].name == "shared" + assert len(tools_b) == 1 + assert tools_b[0].name == "shared" + assert len(tools_global) == 0 + + +async def test_call_tool_with_tenant_id(): + """call_tool respects tenant scope.""" + server = MCPServer("test") + + def tool_a() -> str: + return "result-a" + + def tool_b() -> str: + return "result-b" + + server.add_tool(tool_a, name="do_work", tenant_id="tenant-a") + server.add_tool(tool_b, name="do_work", tenant_id="tenant-b") + + result_a = await server.call_tool("do_work", {}, tenant_id="tenant-a") + result_b = await server.call_tool("do_work", {}, tenant_id="tenant-b") + + # Structured output returns (unstructured_content, structured_content) + assert isinstance(result_a, tuple) + assert isinstance(result_b, tuple) + assert result_a[0][0].text == "result-a" # type: ignore[union-attr] + assert result_a[1] == {"result": "result-a"} + assert result_b[0][0].text == "result-b" # type: ignore[union-attr] + assert result_b[1] == {"result": "result-b"} + + +async def test_call_tool_wrong_tenant_raises(): + """Calling a tool under the wrong tenant raises an error.""" + from mcp.server.mcpserver.exceptions import ToolError + + server = MCPServer("test") + + def my_tool() -> str: # pragma: no cover + return "x" + + server.add_tool(my_tool, tenant_id="tenant-a") + + with pytest.raises(ToolError): + await server.call_tool("my_tool", {}, tenant_id="tenant-b") + + +async def test_list_resources_with_tenant_id(): + """list_resources filters by tenant_id.""" + server = MCPServer("test") + + resource_a = FunctionResource(uri="file:///data", name="data-a", fn=lambda: "a") + resource_b = FunctionResource(uri="file:///data", name="data-b", fn=lambda: "b") + + server.add_resource(resource_a, tenant_id="tenant-a") + server.add_resource(resource_b, tenant_id="tenant-b") + + resources_a = await server.list_resources(tenant_id="tenant-a") + resources_b = await server.list_resources(tenant_id="tenant-b") + resources_global = await server.list_resources() + + assert len(resources_a) == 1 + assert resources_a[0].name == "data-a" + assert len(resources_b) == 1 + assert resources_b[0].name == "data-b" + assert len(resources_global) == 0 + + +async def test_list_resource_templates_with_tenant_id(): + """list_resource_templates filters by tenant_id.""" + server = MCPServer("test") + + def greet_a(name: str) -> str: # pragma: no cover + return f"Hello A, {name}!" + + def greet_b(name: str) -> str: # pragma: no cover + return f"Hello B, {name}!" + + server._resource_manager.add_template( + fn=greet_a, + uri_template="greet://{name}", + tenant_id="tenant-a", + ) + server._resource_manager.add_template( + fn=greet_b, + uri_template="greet://{name}", + tenant_id="tenant-b", + ) + + templates_a = await server.list_resource_templates(tenant_id="tenant-a") + templates_b = await server.list_resource_templates(tenant_id="tenant-b") + templates_global = await server.list_resource_templates() + + assert len(templates_a) == 1 + assert len(templates_b) == 1 + assert len(templates_global) == 0 + + +async def test_read_resource_with_tenant_id(): + """read_resource respects tenant scope.""" + server = MCPServer("test") + + resource = FunctionResource(uri="file:///secret", name="secret", fn=lambda: "secret-data") + server.add_resource(resource, tenant_id="tenant-a") + + # Tenant A can read + results = await server.read_resource("file:///secret", tenant_id="tenant-a") + contents = list(results) + assert len(contents) == 1 + assert contents[0].content == "secret-data" + + # Tenant B cannot + from mcp.server.mcpserver.exceptions import ResourceError + + with pytest.raises(ResourceError, match="Unknown resource"): + await server.read_resource("file:///secret", tenant_id="tenant-b") + + +async def test_list_prompts_with_tenant_id(): + """list_prompts filters by tenant_id.""" + server = MCPServer("test") + + async def prompt_a() -> str: # pragma: no cover + return "Hello from A" + + async def prompt_b() -> str: # pragma: no cover + return "Hello from B" + + server.add_prompt(Prompt.from_function(prompt_a, name="greet"), tenant_id="tenant-a") + server.add_prompt(Prompt.from_function(prompt_b, name="greet"), tenant_id="tenant-b") + + prompts_a = await server.list_prompts(tenant_id="tenant-a") + prompts_b = await server.list_prompts(tenant_id="tenant-b") + prompts_global = await server.list_prompts() + + assert len(prompts_a) == 1 + assert len(prompts_b) == 1 + assert len(prompts_global) == 0 + + +async def test_get_prompt_with_tenant_id(): + """get_prompt respects tenant scope.""" + server = MCPServer("test") + + async def greet_a() -> str: + return "Hello from tenant-a" + + server.add_prompt(Prompt.from_function(greet_a, name="greet"), tenant_id="tenant-a") + + # Tenant A can get the prompt + result = await server.get_prompt("greet", tenant_id="tenant-a") + assert result.messages is not None + assert len(result.messages) > 0 + + # Tenant B cannot + with pytest.raises(ValueError, match="Unknown prompt"): + await server.get_prompt("greet", tenant_id="tenant-b") + + +async def test_remove_tool_with_tenant_id(): + """remove_tool respects tenant scope.""" + server = MCPServer("test") + + def my_tool() -> str: # pragma: no cover + return "x" + + server.add_tool(my_tool, name="my_tool", tenant_id="tenant-a") + server.add_tool(my_tool, name="my_tool", tenant_id="tenant-b") + + server.remove_tool("my_tool", tenant_id="tenant-a") + + tools_a = await server.list_tools(tenant_id="tenant-a") + tools_b = await server.list_tools(tenant_id="tenant-b") + + assert len(tools_a) == 0 + assert len(tools_b) == 1 + + +async def test_remove_resource_with_tenant_id(): + """remove_resource respects tenant scope and does not affect other tenants.""" + server = MCPServer("test") + + resource_a = FunctionResource(uri="file:///data", name="data-a", fn=lambda: "a") + resource_b = FunctionResource(uri="file:///data", name="data-b", fn=lambda: "b") + + server.add_resource(resource_a, tenant_id="tenant-a") + server.add_resource(resource_b, tenant_id="tenant-b") + + server.remove_resource("file:///data", tenant_id="tenant-a") + + resources_a = await server.list_resources(tenant_id="tenant-a") + resources_b = await server.list_resources(tenant_id="tenant-b") + + assert len(resources_a) == 0 + assert len(resources_b) == 1 + assert resources_b[0].name == "data-b" + + # Tenant B can still read their resource + results = await server.read_resource("file:///data", tenant_id="tenant-b") + contents = list(results) + assert len(contents) == 1 + assert contents[0].content == "b" + + # Tenant A's resource is gone + from mcp.server.mcpserver.exceptions import ResourceError + + with pytest.raises(ResourceError, match="Unknown resource"): + await server.read_resource("file:///data", tenant_id="tenant-a") + + +async def test_remove_resource_nonexistent_raises(): + """Removing a non-existent resource raises ValueError.""" + server = MCPServer("test") + + with pytest.raises(ValueError, match="Unknown resource"): + server.remove_resource("file:///nope", tenant_id="tenant-a") + + +async def test_remove_prompt_with_tenant_id(): + """remove_prompt respects tenant scope and does not affect other tenants.""" + server = MCPServer("test") + + async def prompt_a() -> str: # pragma: no cover — removed before rendering + return "Hello from A" + + async def prompt_b() -> str: + return "Hello from B" + + server.add_prompt(Prompt.from_function(prompt_a, name="greet"), tenant_id="tenant-a") + server.add_prompt(Prompt.from_function(prompt_b, name="greet"), tenant_id="tenant-b") + + server.remove_prompt("greet", tenant_id="tenant-a") + + prompts_a = await server.list_prompts(tenant_id="tenant-a") + prompts_b = await server.list_prompts(tenant_id="tenant-b") + + assert len(prompts_a) == 0 + assert len(prompts_b) == 1 + + # Tenant B can still render their prompt + result = await server.get_prompt("greet", tenant_id="tenant-b") + assert result.messages is not None + assert len(result.messages) > 0 + + # Tenant A's prompt is gone + with pytest.raises(ValueError, match="Unknown prompt"): + await server.get_prompt("greet", tenant_id="tenant-a") + + +async def test_remove_prompt_nonexistent_raises(): + """Removing a non-existent prompt raises ValueError.""" + server = MCPServer("test") + + with pytest.raises(ValueError, match="Unknown prompt"): + server.remove_prompt("nope", tenant_id="tenant-a") + + +# --- Backward compatibility --- + + +async def test_backward_compat_no_tenant_id(): + """All public methods work without tenant_id (backward compatible).""" + server = MCPServer("test") + + @server.tool() + def greet(name: str) -> str: + return f"Hello, {name}!" + + @server.resource("test://data") + def test_resource() -> str: + return "data" + + @server.prompt() + def test_prompt() -> str: + return "prompt text" + + # All operations work without tenant_id + tools = await server.list_tools() + assert len(tools) == 1 + + result = await server.call_tool("greet", {"name": "World"}) + assert len(list(result)) > 0 + + resources = await server.list_resources() + assert len(resources) == 1 + + read_result = await server.read_resource("test://data") + assert len(list(read_result)) == 1 + + prompts = await server.list_prompts() + assert len(prompts) == 1 + + prompt_result = await server.get_prompt("test_prompt") + assert prompt_result.messages is not None diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 49b6deb4b..ca33b064a 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -685,6 +685,32 @@ async def test_remove_tool_and_call(self): class TestServerResources: + async def test_init_with_resources(self): + def get_text() -> str: + """Seeded resource.""" + return "Hello from init!" + + resource = FunctionResource.from_function(fn=get_text, uri="resource://init", name="init_resource") + + mcp = MCPServer(resources=[resource]) + + async with Client(mcp) as client: + assert client.initialize_result.capabilities.resources is not None + + resources = await client.list_resources() + assert len(resources.resources) == 1 + listed = resources.resources[0] + assert listed.uri == "resource://init" + assert listed.name == "init_resource" + assert listed.description == "Seeded resource." + + result = await client.read_resource("resource://init") + + assert len(result.contents) == 1 + content = result.contents[0] + assert isinstance(content, TextResourceContents) + assert content.text == "Hello from init!" + async def test_text_resource(self): mcp = MCPServer() @@ -1490,3 +1516,128 @@ async def test_report_progress_passes_related_request_id(): message="halfway", related_request_id="req-abc-123", ) + + +# --- remove_resource / remove_prompt on MCPServer --- + + +async def test_remove_resource(): + """Test removing a resource from the server.""" + mcp = MCPServer() + resource = FunctionResource(uri="resource://test", name="test", fn=lambda: "data") + mcp.add_resource(resource) + + assert len(mcp._resource_manager.list_resources()) == 1 + + mcp.remove_resource("resource://test") + + assert len(mcp._resource_manager.list_resources()) == 0 + + +async def test_remove_nonexistent_resource(): + """Test that removing a non-existent resource raises ValueError.""" + mcp = MCPServer() + + with pytest.raises(ValueError, match="Unknown resource"): + mcp.remove_resource("resource://nope") + + +async def test_remove_resource_and_list(): + """Test that a removed resource doesn't appear in list_resources.""" + mcp = MCPServer() + mcp.add_resource(FunctionResource(uri="resource://a", name="a", fn=lambda: "a")) + mcp.add_resource(FunctionResource(uri="resource://b", name="b", fn=lambda: "b")) + + async with Client(mcp) as client: + resources = await client.list_resources() + assert len(resources.resources) == 2 + + mcp.remove_resource("resource://a") + + async with Client(mcp) as client: + resources = await client.list_resources() + assert len(resources.resources) == 1 + assert resources.resources[0].name == "b" + + +async def test_remove_resource_and_read(): + """Test that reading a removed resource fails.""" + mcp = MCPServer() + mcp.add_resource(FunctionResource(uri="resource://test", name="test", fn=lambda: "data")) + + async with Client(mcp) as client: + result = await client.read_resource("resource://test") + assert isinstance(result.contents[0], TextResourceContents) + assert result.contents[0].text == "data" + + mcp.remove_resource("resource://test") + + async with Client(mcp) as client: + with pytest.raises(MCPError, match="Unknown resource"): + await client.read_resource("resource://test") + + +async def test_remove_prompt(): + """Test removing a prompt from the server.""" + mcp = MCPServer() + + @mcp.prompt() + def greet() -> str: # pragma: no cover + return "Hello" + + assert len(mcp._prompt_manager.list_prompts()) == 1 + + mcp.remove_prompt("greet") + + assert len(mcp._prompt_manager.list_prompts()) == 0 + + +async def test_remove_nonexistent_prompt(): + """Test that removing a non-existent prompt raises ValueError.""" + mcp = MCPServer() + + with pytest.raises(ValueError, match="Unknown prompt"): + mcp.remove_prompt("nope") + + +async def test_remove_prompt_and_list(): + """Test that a removed prompt doesn't appear in list_prompts.""" + mcp = MCPServer() + + @mcp.prompt() + def greet() -> str: # pragma: no cover + return "Hello" + + @mcp.prompt() + def farewell() -> str: # pragma: no cover + return "Goodbye" + + async with Client(mcp) as client: + prompts = await client.list_prompts() + assert len(prompts.prompts) == 2 + + mcp.remove_prompt("greet") + + async with Client(mcp) as client: + prompts = await client.list_prompts() + assert len(prompts.prompts) == 1 + assert prompts.prompts[0].name == "farewell" + + +async def test_remove_prompt_and_get(): + """Test that getting a removed prompt fails.""" + mcp = MCPServer() + + @mcp.prompt() + def greet() -> str: + return "Hello" + + async with Client(mcp) as client: + result = await client.get_prompt("greet") + assert result.messages is not None + + mcp.remove_prompt("greet") + + async with Client(mcp) as client: + with pytest.raises(MCPError, match="Unknown prompt"): + await client.get_prompt("greet") diff --git a/tests/server/test_multi_tenancy_session.py b/tests/server/test_multi_tenancy_session.py new file mode 100644 index 000000000..2f94fa06d --- /dev/null +++ b/tests/server/test_multi_tenancy_session.py @@ -0,0 +1,424 @@ +"""Tests for multi-tenancy support in session and request context.""" + +import time + +import anyio +import pytest +from anyio.lowlevel import checkpoint + +from mcp import Client +from mcp.server import Server +from mcp.server.auth.middleware.auth_context import auth_context_var, get_tenant_id +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser +from mcp.server.auth.provider import AccessToken +from mcp.server.context import ServerRequestContext +from mcp.server.experimental.request_context import Experimental +from mcp.server.models import InitializationOptions +from mcp.server.session import ServerSession +from mcp.shared._context import RequestContext, tenant_id_var +from mcp.shared.message import SessionMessage +from mcp.shared.session import BaseSession +from mcp.types import ListToolsResult, NotificationParams, PaginatedRequestParams, ServerCapabilities + + +def _simulate_tenant_binding(session: ServerSession, tenant_id_value: str) -> None: + """Simulate the set-once tenant binding logic from lowlevel/server.py. + + Sets both auth_context_var (as AuthContextMiddleware does) and tenant_id_var + (the transport-agnostic contextvar that the server reads). + """ + access_token = AccessToken( + token=f"token-{tenant_id_value}", + client_id="client", + scopes=["read"], + expires_at=int(time.time()) + 3600, + tenant_id=tenant_id_value, + ) + user = AuthenticatedUser(access_token) + auth_token = auth_context_var.set(user) + tenant_token = tenant_id_var.set(tenant_id_value) + try: + tenant_id = tenant_id_var.get() + if tenant_id is not None and session.tenant_id is None: + session.tenant_id = tenant_id + finally: + tenant_id_var.reset(tenant_token) + auth_context_var.reset(auth_token) + + +@pytest.fixture +def init_options() -> InitializationOptions: + """Create initialization options for testing.""" + return InitializationOptions( + server_name="test-server", + server_version="1.0.0", + capabilities=ServerCapabilities(), + ) + + +def test_request_context_with_tenant_id(): + """Test RequestContext can hold tenant_id.""" + # Use type: ignore since we're testing the dataclass field, not session behavior + ctx: RequestContext[BaseSession] = RequestContext( # type: ignore[type-arg] + session=None, # type: ignore[arg-type] + request_id="test-1", + tenant_id="tenant-xyz", + ) + assert ctx.tenant_id == "tenant-xyz" + + +def test_request_context_without_tenant_id(): + """Test RequestContext defaults tenant_id to None.""" + ctx: RequestContext[BaseSession] = RequestContext( # type: ignore[type-arg] + session=None, # type: ignore[arg-type] + request_id="test-1", + ) + assert ctx.tenant_id is None + + +def test_server_request_context_with_tenant_id(): + """Test ServerRequestContext can hold tenant_id.""" + ctx = ServerRequestContext( + session=None, # type: ignore[arg-type] + lifespan_context={}, + experimental=Experimental( + task_metadata=None, + _client_capabilities=None, + _session=None, # type: ignore[arg-type] + _task_support=None, + ), + tenant_id="tenant-abc", + ) + assert ctx.tenant_id == "tenant-abc" + + +def test_server_request_context_inherits_tenant_id_from_base(): + """Test ServerRequestContext inherits tenant_id behavior from RequestContext.""" + # Without tenant_id + ctx_no_tenant = ServerRequestContext( + session=None, # type: ignore[arg-type] + lifespan_context={}, + experimental=Experimental( + task_metadata=None, + _client_capabilities=None, + _session=None, # type: ignore[arg-type] + _task_support=None, + ), + ) + assert ctx_no_tenant.tenant_id is None + + # With tenant_id + ctx_with_tenant = ServerRequestContext( + session=None, # type: ignore[arg-type] + lifespan_context={}, + experimental=Experimental( + task_metadata=None, + _client_capabilities=None, + _session=None, # type: ignore[arg-type] + _task_support=None, + ), + tenant_id="my-tenant", + ) + assert ctx_with_tenant.tenant_id == "my-tenant" + + +@pytest.mark.anyio +async def test_server_session_tenant_id_property(init_options: InitializationOptions): + """Test ServerSession tenant_id property with set-once semantics.""" + server_to_client_send, server_to_client_recv = anyio.create_memory_object_stream[SessionMessage](1) + client_to_server_send, client_to_server_recv = anyio.create_memory_object_stream[SessionMessage | Exception](1) + + async with server_to_client_send, server_to_client_recv, client_to_server_send, client_to_server_recv: + async with ServerSession( + client_to_server_recv, + server_to_client_send, + init_options, + ) as session: + # Default tenant_id is None + assert session.tenant_id is None + + # Can set tenant_id + session.tenant_id = "tenant-123" + assert session.tenant_id == "tenant-123" + + # Setting to the same value is allowed + session.tenant_id = "tenant-123" + assert session.tenant_id == "tenant-123" + + # Cannot change to a different value + with pytest.raises(ValueError, match="Cannot change tenant_id"): + session.tenant_id = "tenant-456" + + # Cannot reset to None once set + with pytest.raises(ValueError, match="Cannot change tenant_id"): + session.tenant_id = None + + # Original value is preserved + assert session.tenant_id == "tenant-123" + + +def test_get_tenant_id_from_auth_context(): + """Test get_tenant_id extracts tenant_id from auth context.""" + # No auth context + assert get_tenant_id() is None + + # With auth context but no tenant + access_token_no_tenant = AccessToken( + token="token1", + client_id="client1", + scopes=["read"], + expires_at=int(time.time()) + 3600, + ) + user_no_tenant = AuthenticatedUser(access_token_no_tenant) + token = auth_context_var.set(user_no_tenant) + try: + assert get_tenant_id() is None + finally: + auth_context_var.reset(token) + + # With auth context and tenant + access_token_with_tenant = AccessToken( + token="token2", + client_id="client2", + scopes=["read"], + expires_at=int(time.time()) + 3600, + tenant_id="tenant-xyz", + ) + user_with_tenant = AuthenticatedUser(access_token_with_tenant) + token = auth_context_var.set(user_with_tenant) + try: + assert get_tenant_id() == "tenant-xyz" + finally: + auth_context_var.reset(token) + + +@pytest.mark.anyio +async def test_session_tenant_id_set_from_auth_context_on_first_request(init_options: InitializationOptions): + """Verify session.tenant_id is populated from auth context on the first request. + + The lowlevel server sets session.tenant_id from get_tenant_id() on the + first request that has a tenant. This test simulates that behavior directly. + """ + server_to_client_send, server_to_client_recv = anyio.create_memory_object_stream[SessionMessage](1) + client_to_server_send, client_to_server_recv = anyio.create_memory_object_stream[SessionMessage | Exception](1) + + async with server_to_client_send, server_to_client_recv, client_to_server_send, client_to_server_recv: + async with ServerSession( + client_to_server_recv, + server_to_client_send, + init_options, + ) as session: + assert session.tenant_id is None + + # Simulate what lowlevel/server.py does: set session.tenant_id + # from auth context on first request + _simulate_tenant_binding(session, "tenant-first") + assert session.tenant_id == "tenant-first" + + # Simulate a second request with a different tenant — + # session.tenant_id should NOT change (set-once on first request) + _simulate_tenant_binding(session, "tenant-second") + + # Still the first tenant — not overwritten + assert session.tenant_id == "tenant-first" + + +@pytest.mark.anyio +async def test_tenant_context_isolation_between_concurrent_requests(): + """Verify tenant_id doesn't leak between concurrent async contexts. + + This test validates a critical security property: when multiple requests + from different tenants are processed concurrently, each request must only + see its own tenant_id, never another tenant's. + + How it works: + 1. We simulate two concurrent requests, each with a different tenant_id + ("tenant-A" and "tenant-B"). + + 2. Each simulated request: + - Creates an AccessToken with its tenant_id + - Sets it in the auth_context_var (the contextvar used for auth state) + - Yields control via checkpoint() to allow the other task to run + - Reads back the tenant_id via get_tenant_id() + - Stores the result for verification + + 3. The anyio.lowlevel.checkpoint() forces a context switch, creating + an opportunity for tenant context to "leak" if the isolation is + broken. Without proper contextvar isolation, task2 might see + task1's tenant_id (or vice versa) after the context switch. + + 4. We use anyio.create_task_group() to run both tasks truly concurrently, + not sequentially. This is essential for testing isolation. + + 5. Finally, we verify each request saw only its own tenant_id. + + If this test fails, it indicates a serious security issue where tenant + data could leak between concurrent requests. + """ + # Store results from each simulated request + results: dict[str, str | None] = {} + + async def simulate_request(tenant_id: str, request_key: str) -> None: + """Simulate a request with a specific tenant context. + + Args: + tenant_id: The tenant_id to set in the auth context + request_key: A key to identify this request's result + """ + # Create an access token with the tenant_id, simulating what + # the auth middleware does when a request comes in + access_token = AccessToken( + token=f"token-{request_key}", + client_id="test-client", + scopes=["read"], + expires_at=int(time.time()) + 3600, + tenant_id=tenant_id, + ) + user = AuthenticatedUser(access_token) + + # Set both contextvars - this is what AuthContextMiddleware does + auth_token = auth_context_var.set(user) + tenant_token = tenant_id_var.set(tenant_id) + try: + # Yield control to allow other tasks to run. This is the critical + # point where context leakage could occur if isolation is broken. + await checkpoint() + + # Read back the tenant_id - should still be our tenant, not the other + results[request_key] = tenant_id_var.get() + finally: + # Always reset the context (mirrors middleware behavior) + tenant_id_var.reset(tenant_token) + auth_context_var.reset(auth_token) + + # Run both requests concurrently using a task group + async with anyio.create_task_group() as tg: + tg.start_soon(simulate_request, "tenant-A", "request1") + tg.start_soon(simulate_request, "tenant-B", "request2") + + # Verify isolation: each request should see only its own tenant_id + assert results["request1"] == "tenant-A", "Request 1 saw wrong tenant_id" + assert results["request2"] == "tenant-B", "Request 2 saw wrong tenant_id" + + +@pytest.mark.anyio +async def test_server_session_isolation_between_instances(init_options: InitializationOptions): + """Verify tenant_id is isolated between separate ServerSession instances. + + This test ensures that setting tenant_id on one ServerSession does not + affect another ServerSession instance. Each session should maintain its + own independent tenant context. + + This is important for scenarios where a server handles multiple sessions + concurrently - each session belongs to a specific tenant and must not + see or affect other tenants' sessions. + """ + # Create streams for two independent sessions + send1, recv1 = anyio.create_memory_object_stream[SessionMessage](1) + send2, recv2 = anyio.create_memory_object_stream[SessionMessage | Exception](1) + send3, recv3 = anyio.create_memory_object_stream[SessionMessage](1) + send4, recv4 = anyio.create_memory_object_stream[SessionMessage | Exception](1) + + async with send1, recv1, send2, recv2, send3, recv3, send4, recv4: + # Create two separate server sessions + async with ( + ServerSession(recv2, send1, init_options) as session1, + ServerSession(recv4, send3, init_options) as session2, + ): + # Set different tenant_ids on each session + session1.tenant_id = "tenant-alpha" + session2.tenant_id = "tenant-beta" + + # Verify each session maintains its own tenant_id + assert session1.tenant_id == "tenant-alpha" + assert session2.tenant_id == "tenant-beta" + + # Attempting to change one session's tenant_id raises + with pytest.raises(ValueError, match="Cannot change tenant_id"): + session1.tenant_id = "tenant-gamma" + + # Both sessions retain their original values + assert session1.tenant_id == "tenant-alpha" + assert session2.tenant_id == "tenant-beta" + + +@pytest.mark.anyio +async def test_handle_request_populates_session_tenant_id(): + """E2E: session.tenant_id is set from auth context during request handling. + + This exercises the set-once tenant binding in lowlevel/server.py + _handle_request, covering the branch where get_tenant_id() returns + a non-None value. + """ + captured_ctx_tenant: str | None = None + captured_session_tenant: str | None = None + + async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + nonlocal captured_ctx_tenant, captured_session_tenant + captured_ctx_tenant = ctx.tenant_id + captured_session_tenant = ctx.session.tenant_id + return ListToolsResult(tools=[]) + + server = Server("test", on_list_tools=handle_list_tools) + + # Set auth context with tenant before entering the Client — + # contextvars are inherited by child tasks, so the server will see it + access_token = AccessToken( + token="test-token", + client_id="test-client", + scopes=["read"], + expires_at=int(time.time()) + 3600, + tenant_id="tenant-e2e", + ) + user = AuthenticatedUser(access_token) + auth_token = auth_context_var.set(user) + tenant_token = tenant_id_var.set("tenant-e2e") + try: + async with Client(server) as client: + await client.list_tools() + finally: + tenant_id_var.reset(tenant_token) + auth_context_var.reset(auth_token) + + assert captured_ctx_tenant == "tenant-e2e" + assert captured_session_tenant == "tenant-e2e" + + +@pytest.mark.anyio +async def test_handle_notification_populates_session_tenant_id(): + """E2E: session.tenant_id is set from auth context during notification handling. + + This exercises the set-once tenant binding in lowlevel/server.py + _handle_notification, covering the branch where get_tenant_id() returns + a non-None value. + """ + notification_tenant: str | None = None + notification_received = anyio.Event() + + async def handle_roots_list_changed(ctx: ServerRequestContext, params: NotificationParams | None) -> None: + nonlocal notification_tenant + notification_tenant = ctx.tenant_id + notification_received.set() + + server = Server("test", on_roots_list_changed=handle_roots_list_changed) + + access_token = AccessToken( + token="test-token", + client_id="test-client", + scopes=["read"], + expires_at=int(time.time()) + 3600, + tenant_id="tenant-notify", + ) + user = AuthenticatedUser(access_token) + auth_token = auth_context_var.set(user) + tenant_token = tenant_id_var.set("tenant-notify") + try: + async with Client(server) as client: + await client.session.send_roots_list_changed() + with anyio.fail_after(5): + await notification_received.wait() + finally: + tenant_id_var.reset(tenant_token) + auth_context_var.reset(auth_token) + + assert notification_tenant == "tenant-notify" diff --git a/tests/server/test_streamable_http_manager.py b/tests/server/test_streamable_http_manager.py index 47cfbf14a..35caa1229 100644 --- a/tests/server/test_streamable_http_manager.py +++ b/tests/server/test_streamable_http_manager.py @@ -413,3 +413,216 @@ def test_session_idle_timeout_rejects_non_positive(): 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) + + +# --- Multi-tenancy: session-level tenant isolation --- + + +def _extract_session_id(messages: list[Message]) -> str | None: + """Extract the MCP session ID from ASGI response messages.""" + for msg in messages: + if msg["type"] == "http.response.start": + for header_name, header_value in msg.get("headers", []): + if header_name.decode().lower() == MCP_SESSION_ID_HEADER.lower(): + return header_value.decode() + return None + + +def _extract_status(messages: list[Message]) -> int | None: + """Extract the HTTP status code from ASGI response messages.""" + for msg in messages: + if msg["type"] == "http.response.start": + return msg["status"] + return None + + +def test_extract_session_id_skips_non_start_messages(): + """_extract_session_id skips non-start messages and returns None when no ID found.""" + body_msg: Message = {"type": "http.response.body", "body": b"data"} + start_no_header: Message = {"type": "http.response.start", "status": 200, "headers": []} + + # Only body messages → None + assert _extract_session_id([body_msg]) is None + # Start message without session header → None + assert _extract_session_id([body_msg, start_no_header]) is None + + +def test_extract_status_skips_non_start_messages(): + """_extract_status skips non-start messages and returns None when empty.""" + body_msg: Message = {"type": "http.response.body", "body": b"data"} + start_msg: Message = {"type": "http.response.start", "status": 200, "headers": []} + + # Only body messages → None + assert _extract_status([body_msg]) is None + # Body then start → returns status from start + assert _extract_status([body_msg, start_msg]) == 200 + # Empty list → None + assert _extract_status([]) is None + + +def _make_scope(session_id: str | None = None) -> dict[str, Any]: + """Build a minimal ASGI scope for testing, optionally with a session ID.""" + headers: list[tuple[bytes, bytes]] = [(b"content-type", b"application/json")] + if session_id is not None: + headers.append((b"mcp-session-id", session_id.encode())) + return {"type": "http", "method": "POST", "path": "/mcp", "headers": headers} + + +async def _mock_send(messages: list[Message], message: Message) -> None: + """Async send that collects messages.""" + messages.append(message) + + +async def _mock_receive() -> dict[str, Any]: # pragma: no cover + return {"type": "http.request", "body": b"", "more_body": False} + + +def _set_tenant(tenant: str | None) -> Any: + """Set tenant_id_var and return the reset token.""" + from mcp.shared._context import tenant_id_var + + return tenant_id_var.set(tenant) + + +def _reset_tenant(token: Any) -> None: + """Reset tenant_id_var to its previous value.""" + from mcp.shared._context import tenant_id_var + + tenant_id_var.reset(token) + + +async def _create_session_blocking( + manager: StreamableHTTPSessionManager, + app: Server[Any], + stop_event: anyio.Event, + tenant: str | None = None, +) -> str: + """Create a session whose server stays alive until stop_event is set.""" + + async def blocking_run(*args: Any, **kwargs: Any) -> None: + await stop_event.wait() + + app.run = AsyncMock(side_effect=blocking_run) + + messages: list[Message] = [] + token = _set_tenant(tenant) + try: + await manager.handle_request(_make_scope(), _mock_receive, lambda msg, _msgs=messages: _mock_send(_msgs, msg)) + finally: + _reset_tenant(token) + + session_id = _extract_session_id(messages) + assert session_id is not None + return session_id + + +async def _access_session( + manager: StreamableHTTPSessionManager, + session_id: str, + tenant: str | None = None, +) -> int | None: + """Access an existing session and return the HTTP status code.""" + messages: list[Message] = [] + token = _set_tenant(tenant) + try: + await manager.handle_request( + _make_scope(session_id), _mock_receive, lambda msg, _msgs=messages: _mock_send(_msgs, msg) + ) + finally: + _reset_tenant(token) + + return _extract_status(messages) + + +@pytest.mark.anyio +async def test_tenant_mismatch_returns_404(running_manager: tuple[StreamableHTTPSessionManager, Server]): + """A request from tenant-b cannot access a session created by tenant-a.""" + manager, app = running_manager + stop = anyio.Event() + try: + session_id = await _create_session_blocking(manager, app, stop, tenant="tenant-a") + assert await _access_session(manager, session_id, tenant="tenant-b") == 404 + finally: + stop.set() + + +@pytest.mark.anyio +async def test_two_tenants_cannot_access_each_others_sessions( + running_manager: tuple[StreamableHTTPSessionManager, Server], +): + """Two tenants each create a session; neither can access the other's.""" + manager, app = running_manager + stop = anyio.Event() + try: + session_a = await _create_session_blocking(manager, app, stop, tenant="tenant-a") + session_b = await _create_session_blocking(manager, app, stop, tenant="tenant-b") + assert session_a != session_b + + # Tenant-a tries to access tenant-b's session → 404 + assert await _access_session(manager, session_b, tenant="tenant-a") == 404 + # Tenant-b tries to access tenant-a's session → 404 + assert await _access_session(manager, session_a, tenant="tenant-b") == 404 + finally: + stop.set() + + +@pytest.mark.anyio +async def test_same_tenant_can_reuse_session(running_manager: tuple[StreamableHTTPSessionManager, Server]): + """A request from the same tenant can access its own session.""" + manager, app = running_manager + stop = anyio.Event() + try: + session_id = await _create_session_blocking(manager, app, stop, tenant="tenant-a") + status = await _access_session(manager, session_id, tenant="tenant-a") + assert status != 404, "Same tenant should be able to reuse its own session" + finally: + stop.set() + + +@pytest.mark.anyio +async def test_no_tenant_session_allows_any_access(running_manager: tuple[StreamableHTTPSessionManager, Server]): + """Sessions created without a tenant (no auth) allow access from any request.""" + manager, app = running_manager + stop = anyio.Event() + try: + session_id = await _create_session_blocking(manager, app, stop, tenant=None) + status = await _access_session(manager, session_id, tenant="tenant-a") + assert status != 404, "Session without tenant binding should allow access from any tenant" + finally: + stop.set() + + +@pytest.mark.anyio +async def test_unauthenticated_request_cannot_access_tenant_session( + running_manager: tuple[StreamableHTTPSessionManager, Server], +): + """A request with no tenant cannot access a session bound to a tenant.""" + manager, app = running_manager + stop = anyio.Event() + try: + session_id = await _create_session_blocking(manager, app, stop, tenant="tenant-a") + assert await _access_session(manager, session_id, tenant=None) == 404 + finally: + stop.set() + + +@pytest.mark.anyio +async def test_session_tenant_cleanup_on_exit(running_manager: tuple[StreamableHTTPSessionManager, Server]): + """Tenant mapping is cleaned up when a session exits.""" + manager, app = running_manager + app.run = AsyncMock(return_value=None) + + messages: list[Message] = [] + token = _set_tenant("tenant-a") + try: + await manager.handle_request(_make_scope(), _mock_receive, lambda msg, _msgs=messages: _mock_send(_msgs, msg)) + finally: + _reset_tenant(token) + + session_id = _extract_session_id(messages) + assert session_id is not None + + # Wait for the mock server to complete and cleanup to run + await anyio.sleep(0.01) + + assert session_id not in manager._session_tenants, "Tenant mapping should be cleaned up after session exits" diff --git a/tests/shared/test_auth.py b/tests/shared/test_auth.py index cd3c35332..7463bc5a8 100644 --- a/tests/shared/test_auth.py +++ b/tests/shared/test_auth.py @@ -1,6 +1,9 @@ """Tests for OAuth 2.0 shared code.""" -from mcp.shared.auth import OAuthMetadata +import pytest +from pydantic import ValidationError + +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata def test_oauth(): @@ -58,3 +61,80 @@ def test_oauth_with_jarm(): "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"], } ) + + +# RFC 7591 §2 marks client_uri/logo_uri/tos_uri/policy_uri/jwks_uri as OPTIONAL. +# Some authorization servers echo the client's omitted metadata back as "" +# instead of dropping the keys; without coercion, AnyHttpUrl rejects "" and +# the whole registration response is thrown away even though the server +# returned a valid client_id. + + +@pytest.mark.parametrize( + "empty_field", + ["client_uri", "logo_uri", "tos_uri", "policy_uri", "jwks_uri"], +) +def test_optional_url_empty_string_coerced_to_none(empty_field: str): + data = { + "redirect_uris": ["https://example.com/callback"], + empty_field: "", + } + metadata = OAuthClientMetadata.model_validate(data) + assert getattr(metadata, empty_field) is None + + +def test_all_optional_urls_empty_together(): + data = { + "redirect_uris": ["https://example.com/callback"], + "client_uri": "", + "logo_uri": "", + "tos_uri": "", + "policy_uri": "", + "jwks_uri": "", + } + metadata = OAuthClientMetadata.model_validate(data) + assert metadata.client_uri is None + assert metadata.logo_uri is None + assert metadata.tos_uri is None + assert metadata.policy_uri is None + assert metadata.jwks_uri is None + + +def test_valid_url_passes_through_unchanged(): + data = { + "redirect_uris": ["https://example.com/callback"], + "client_uri": "https://udemy.com/", + } + metadata = OAuthClientMetadata.model_validate(data) + assert str(metadata.client_uri) == "https://udemy.com/" + + +def test_information_full_inherits_coercion(): + """OAuthClientInformationFull subclasses OAuthClientMetadata, so the + same coercion applies to DCR responses parsed via the full model.""" + data = { + "client_id": "abc123", + "redirect_uris": ["https://example.com/callback"], + "client_uri": "", + "logo_uri": "", + "tos_uri": "", + "policy_uri": "", + "jwks_uri": "", + } + info = OAuthClientInformationFull.model_validate(data) + assert info.client_id == "abc123" + assert info.client_uri is None + assert info.logo_uri is None + assert info.tos_uri is None + assert info.policy_uri is None + assert info.jwks_uri is None + + +def test_invalid_non_empty_url_still_rejected(): + """Coercion must only touch empty strings — garbage URLs still raise.""" + data = { + "redirect_uris": ["https://example.com/callback"], + "client_uri": "not a url", + } + with pytest.raises(ValidationError): + OAuthClientMetadata.model_validate(data) diff --git a/tests/shared/test_context.py b/tests/shared/test_context.py new file mode 100644 index 000000000..7be966e26 --- /dev/null +++ b/tests/shared/test_context.py @@ -0,0 +1,77 @@ +"""Tests for mcp.shared._context utilities.""" + +import contextvars +from typing import Any + +from mcp.shared._context import merge_contexts, tenant_id_var + +_SENDER_VAR: contextvars.ContextVar[str] = contextvars.ContextVar("_sender_var") +_SERVER_VAR: contextvars.ContextVar[str] = contextvars.ContextVar("_server_var") + + +def _context_with(*pairs: tuple[contextvars.ContextVar[Any], Any]) -> contextvars.Context: + def _setup() -> contextvars.Context: + for var, val in pairs: + var.set(val) + return contextvars.copy_context() + + return contextvars.copy_context().run(_setup) + + +def test_merge_sender_only_vars(): + sender = _context_with((_SENDER_VAR, "from-client")) + server = _context_with() + + merged = merge_contexts(sender, server) + assert merged[_SENDER_VAR] == "from-client" + + +def test_merge_server_only_vars(): + sender = _context_with() + server = _context_with((_SERVER_VAR, "from-server")) + + merged = merge_contexts(sender, server) + assert merged[_SERVER_VAR] == "from-server" + + +def test_merge_both_present(): + sender = _context_with((_SENDER_VAR, "from-client")) + server = _context_with((_SERVER_VAR, "from-server")) + + merged = merge_contexts(sender, server) + assert merged[_SENDER_VAR] == "from-client" + assert merged[_SERVER_VAR] == "from-server" + + +def test_merge_server_wins_on_conflict(): + shared_var: contextvars.ContextVar[str] = contextvars.ContextVar("shared") + sender = _context_with((shared_var, "sender-value")) + server = _context_with((shared_var, "server-value")) + + merged = merge_contexts(sender, server) + assert merged[shared_var] == "server-value" + + +def test_merge_server_wins_tenant_id_spoof(): + """A sender context that sets tenant_id_var must be overridden by the server.""" + sender = _context_with((tenant_id_var, "spoofed-tenant")) + server = _context_with((tenant_id_var, "real-tenant")) + + merged = merge_contexts(sender, server) + assert merged[tenant_id_var] == "real-tenant" + + +def test_merge_empty_sender(): + sender = _context_with() + server = _context_with((_SERVER_VAR, "from-server")) + + merged = merge_contexts(sender, server) + assert merged[_SERVER_VAR] == "from-server" + + +def test_merge_empty_server(): + sender = _context_with((_SENDER_VAR, "from-client")) + server = _context_with() + + merged = merge_contexts(sender, server) + assert merged[_SENDER_VAR] == "from-client" diff --git a/tests/shared/test_otel.py b/tests/shared/test_otel.py new file mode 100644 index 000000000..ec7ff78cc --- /dev/null +++ b/tests/shared/test_otel.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import pytest +from logfire.testing import CaptureLogfire + +from mcp import types +from mcp.client.client import Client +from mcp.server.mcpserver import MCPServer + +pytestmark = pytest.mark.anyio + + +# Logfire warns about propagated trace context by default (distributed_tracing=None). +# This is expected here since we're testing cross-boundary context propagation. +@pytest.mark.filterwarnings("ignore::RuntimeWarning") +async def test_client_and_server_spans(capfire: CaptureLogfire): + """Verify that calling a tool produces client and server spans with correct attributes.""" + server = MCPServer("test") + + @server.tool() + def greet(name: str) -> str: + """Greet someone.""" + return f"Hello, {name}!" + + async with Client(server) as client: + result = await client.call_tool("greet", {"name": "World"}) + + assert isinstance(result.content[0], types.TextContent) + assert result.content[0].text == "Hello, World!" + + spans = capfire.exporter.exported_spans_as_dict() + span_names = {s["name"] for s in spans} + + assert "MCP send tools/call greet" in span_names + assert "MCP handle tools/call greet" in span_names + + client_span = next(s for s in spans if s["name"] == "MCP send tools/call greet") + server_span = next(s for s in spans if s["name"] == "MCP handle tools/call greet") + + assert client_span["attributes"]["mcp.method.name"] == "tools/call" + assert server_span["attributes"]["mcp.method.name"] == "tools/call" + + # Server span should be in the same trace as the client span (context propagation). + assert server_span["context"]["trace_id"] == client_span["context"]["trace_id"] diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index f8ca30441..3d5770fb6 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -45,6 +45,7 @@ from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from mcp.server.transport_security import TransportSecuritySettings from mcp.shared._context import RequestContext +from mcp.shared._context_streams import create_context_streams from mcp.shared._httpx_utils import ( MCP_DEFAULT_SSE_READ_TIMEOUT, MCP_DEFAULT_TIMEOUT, @@ -1783,8 +1784,8 @@ async def test_handle_sse_event_skips_empty_data(): # Create a mock SSE event with empty data (keep-alive ping) mock_sse = ServerSentEvent(event="message", data="", id=None, retry=None) - # Create a mock stream writer - write_stream, read_stream = anyio.create_memory_object_stream[SessionMessage | Exception](1) + # Create a context-aware stream writer (matches StreamWriter type alias) + write_stream, read_stream = create_context_streams[SessionMessage | Exception](1) try: # Call _handle_sse_event with empty data - should return False and not raise @@ -1794,8 +1795,9 @@ async def test_handle_sse_event_skips_empty_data(): assert result is False # Nothing should have been written to the stream - # Check buffer is empty (statistics().current_buffer_used returns buffer size) - assert write_stream.statistics().current_buffer_used == 0 + with pytest.raises(TimeoutError): + with anyio.fail_after(0): + await read_stream.receive() finally: await write_stream.aclose() await read_stream.aclose() diff --git a/uv.lock b/uv.lock index 8f9a5396a..cb6918f03 100644 --- a/uv.lock +++ b/uv.lock @@ -13,6 +13,7 @@ members = [ "mcp-simple-auth", "mcp-simple-auth-client", "mcp-simple-chatbot", + "mcp-simple-multi-tenant", "mcp-simple-pagination", "mcp-simple-prompt", "mcp-simple-resource", @@ -448,62 +449,62 @@ toml = [ [[package]] name = "cryptography" -version = "46.0.5" +version = "46.0.7" 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/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" }, +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, + { url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" }, + { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" }, ] [[package]] @@ -579,6 +580,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.73.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/c0/4a54c386282c13449eca8bbe2ddb518181dc113e78d240458a68856b4d69/googleapis_common_protos-1.73.1.tar.gz", hash = "sha256:13114f0e9d2391756a0194c3a8131974ed7bffb06086569ba193364af59163b6", size = 147506, upload-time = "2026-03-26T22:17:38.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/82/fcb6520612bec0c39b973a6c0954b6a0d948aadfe8f7e9487f60ceb8bfa6/googleapis_common_protos-1.73.1-py3-none-any.whl", hash = "sha256:e51f09eb0a43a8602f5a915870972e6b4a394088415c79d79605a46d8e826ee8", size = 297556, upload-time = "2026-03-26T22:15:58.455Z" }, +] + [[package]] name = "griffe" version = "1.14.0" @@ -646,6 +659,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -710,6 +735,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "logfire" +version = "4.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "executing" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-sdk" }, + { name = "protobuf" }, + { name = "rich" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/fc/21f923243d8c3ca2ebfa97de46970ced734e66ac634c1c35b6abb41300f1/logfire-4.31.0.tar.gz", hash = "sha256:361bfda17c9d70ada5d220211033bae06b871ddac9d5b06978bc0ceca6b8e658", size = 1080609, upload-time = "2026-03-27T19:00:46.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/1a/8c860e35bf847ac0d647d94bad89dccbb66cbcafdd61d8334f8cc7cfdd58/logfire-4.31.0-py3-none-any.whl", hash = "sha256:49fad38b5e6f199a98e9c8814e860c8a42595bb81479b52a20413e53ee475b72", size = 308896, upload-time = "2026-03-27T19:00:43.107Z" }, +] + [[package]] name = "markdown" version = "3.9" @@ -797,6 +841,7 @@ dependencies = [ { name = "httpx" }, { name = "httpx-sse" }, { name = "jsonschema" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, @@ -826,6 +871,7 @@ dev = [ { name = "coverage", extra = ["toml"] }, { name = "dirty-equals" }, { name = "inline-snapshot" }, + { name = "logfire" }, { name = "mcp", extra = ["cli", "ws"] }, { name = "pillow" }, { name = "pyright" }, @@ -853,6 +899,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27.1,<1.0.0" }, { name = "httpx-sse", specifier = ">=0.4" }, { name = "jsonschema", specifier = ">=4.20.0" }, + { name = "opentelemetry-api", specifier = ">=1.28.0" }, { name = "pydantic", specifier = ">=2.12.0" }, { name = "pydantic-settings", specifier = ">=2.5.2" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10.1" }, @@ -876,6 +923,7 @@ dev = [ { 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 = "logfire", specifier = ">=3.0.0" }, { name = "mcp", extras = ["cli", "ws"], editable = "." }, { name = "pillow", specifier = ">=12.0" }, { name = "pyright", specifier = ">=1.1.400" }, @@ -1035,6 +1083,35 @@ dev = [ { name = "ruff", specifier = ">=0.6.9" }, ] +[[package]] +name = "mcp-simple-multi-tenant" +version = "0.1.0" +source = { editable = "examples/servers/simple-multi-tenant" } +dependencies = [ + { name = "click" }, + { name = "mcp" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.2.0" }, + { name = "mcp", editable = "." }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.378" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + [[package]] name = "mcp-simple-pagination" version = "0.1.0" @@ -1642,6 +1719,103 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/04/2a08fa9c0214ae38880df01e8bfae12b067ec0793446578575e5080d6545/opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb", size = 17288, upload-time = "2025-12-11T13:32:42.029Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706, upload-time = "2025-12-11T13:36:42.515Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096, upload-time = "2025-12-11T13:35:33.067Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, +] + [[package]] name = "outcome" version = "1.3.0.post0" @@ -1797,6 +1971,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + [[package]] name = "pycparser" version = "2.23" @@ -2235,7 +2424,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.33.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -2243,9 +2432,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, ] [[package]] @@ -2766,3 +2955,81 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/23/bb82321b86411eb51e5a5db3fb8f8032fd30bd7c2d74bfe936136b2fa1d6/wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04", size = 53482, upload-time = "2025-08-12T05:51:44.467Z" }, + { url = "https://files.pythonhosted.org/packages/45/69/f3c47642b79485a30a59c63f6d739ed779fb4cc8323205d047d741d55220/wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2", size = 38676, upload-time = "2025-08-12T05:51:32.636Z" }, + { url = "https://files.pythonhosted.org/packages/d1/71/e7e7f5670c1eafd9e990438e69d8fb46fa91a50785332e06b560c869454f/wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c", size = 38957, upload-time = "2025-08-12T05:51:54.655Z" }, + { url = "https://files.pythonhosted.org/packages/de/17/9f8f86755c191d6779d7ddead1a53c7a8aa18bccb7cea8e7e72dfa6a8a09/wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775", size = 81975, upload-time = "2025-08-12T05:52:30.109Z" }, + { url = "https://files.pythonhosted.org/packages/f2/15/dd576273491f9f43dd09fce517f6c2ce6eb4fe21681726068db0d0467096/wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd", size = 83149, upload-time = "2025-08-12T05:52:09.316Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c4/5eb4ce0d4814521fee7aa806264bf7a114e748ad05110441cd5b8a5c744b/wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05", size = 82209, upload-time = "2025-08-12T05:52:10.331Z" }, + { url = "https://files.pythonhosted.org/packages/31/4b/819e9e0eb5c8dc86f60dfc42aa4e2c0d6c3db8732bce93cc752e604bb5f5/wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418", size = 81551, upload-time = "2025-08-12T05:52:31.137Z" }, + { url = "https://files.pythonhosted.org/packages/f8/83/ed6baf89ba3a56694700139698cf703aac9f0f9eb03dab92f57551bd5385/wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390", size = 36464, upload-time = "2025-08-12T05:53:01.204Z" }, + { url = "https://files.pythonhosted.org/packages/2f/90/ee61d36862340ad7e9d15a02529df6b948676b9a5829fd5e16640156627d/wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6", size = 38748, upload-time = "2025-08-12T05:53:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c3/cefe0bd330d389c9983ced15d326f45373f4073c9f4a8c2f99b50bfea329/wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18", size = 36810, upload-time = "2025-08-12T05:52:51.906Z" }, + { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, + { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, + { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, + { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +]