Spaces:
Configuration error
Configuration error
Upload folder using huggingface_hub (part 3)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/INSTALLER +1 -0
- .mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/METADATA +175 -0
- .mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/RECORD +253 -0
- .mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/REQUESTED +0 -0
- .mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/WHEEL +4 -0
- .mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/entry_points.txt +2 -0
- .mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/licenses/LICENSE +21 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/client/auth/extensions/client_credentials.py +333 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/client/auth/extensions/identity_assertion.py +213 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/client/auth/oauth2.py +785 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/client/auth/utils.py +427 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/client/caching.py +387 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/client/client.py +950 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/client/context.py +5 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/client/extension.py +196 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/client/session.py +1462 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/client/session_group.py +449 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/client/sse.py +161 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/client/stdio.py +354 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/client/streamable_http.py +710 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/client/subscriptions.py +282 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/os/__init__.py +1 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/os/posix/__init__.py +1 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/os/posix/utilities.py +63 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/os/win32/__init__.py +1 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/os/win32/utilities.py +292 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/py.typed +0 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/server/__init__.py +7 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/server/__main__.py +24 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/server/_otel.py +72 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/server/_streamable_http_modern.py +464 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/server/apps.py +241 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/__init__.py +1 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/errors.py +5 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/__init__.py +1 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/authorize.py +225 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/metadata.py +29 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/register.py +154 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/revoke.py +87 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/token.py +275 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/json_response.py +10 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/middleware/__init__.py +1 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/middleware/auth_context.py +46 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/middleware/bearer_auth.py +143 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/middleware/client_auth.py +119 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/provider.py +371 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/routes.py +262 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/settings.py +42 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/server/caching.py +85 -0
- .mcp-venv/lib/python3.13/site-packages/mcp/server/connection.py +493 -0
.mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/INSTALLER
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
pip
|
.mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/METADATA
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Metadata-Version: 2.4
|
| 2 |
+
Name: mcp
|
| 3 |
+
Version: 2.0.0
|
| 4 |
+
Summary: Model Context Protocol SDK
|
| 5 |
+
Project-URL: Homepage, https://modelcontextprotocol.io
|
| 6 |
+
Project-URL: Documentation, https://py.sdk.modelcontextprotocol.io/
|
| 7 |
+
Project-URL: Repository, https://github.com/modelcontextprotocol/python-sdk
|
| 8 |
+
Project-URL: Issues, https://github.com/modelcontextprotocol/python-sdk/issues
|
| 9 |
+
Author: Model Context Protocol a Series of LF Projects, LLC.
|
| 10 |
+
Maintainer-email: David Soria Parra <davidsp@anthropic.com>, Marcelo Trylesinski <marcelotryle@gmail.com>, Max Isbey <maxisbey@anthropic.com>, Felix Weinberger <fweinberger@anthropic.com>
|
| 11 |
+
License: MIT
|
| 12 |
+
License-File: LICENSE
|
| 13 |
+
Keywords: automation,llm,mcp
|
| 14 |
+
Classifier: Development Status :: 5 - Production/Stable
|
| 15 |
+
Classifier: Intended Audience :: Developers
|
| 16 |
+
Classifier: License :: OSI Approved :: MIT License
|
| 17 |
+
Classifier: Programming Language :: Python :: 3
|
| 18 |
+
Classifier: Programming Language :: Python :: 3.10
|
| 19 |
+
Classifier: Programming Language :: Python :: 3.11
|
| 20 |
+
Classifier: Programming Language :: Python :: 3.12
|
| 21 |
+
Classifier: Programming Language :: Python :: 3.13
|
| 22 |
+
Classifier: Programming Language :: Python :: 3.14
|
| 23 |
+
Requires-Python: >=3.10
|
| 24 |
+
Requires-Dist: anyio>=4.10; python_version >= '3.14'
|
| 25 |
+
Requires-Dist: anyio>=4.9; python_version < '3.14'
|
| 26 |
+
Requires-Dist: httpx2>=2.5.0
|
| 27 |
+
Requires-Dist: jsonschema>=4.20.0
|
| 28 |
+
Requires-Dist: mcp-types==2.0.0
|
| 29 |
+
Requires-Dist: opentelemetry-api>=1.28.0
|
| 30 |
+
Requires-Dist: pydantic>=2.12.0
|
| 31 |
+
Requires-Dist: pyjwt[crypto]>=2.10.1
|
| 32 |
+
Requires-Dist: python-multipart>=0.0.9
|
| 33 |
+
Requires-Dist: pywin32>=311; sys_platform == 'win32'
|
| 34 |
+
Requires-Dist: sse-starlette>=3.0.0
|
| 35 |
+
Requires-Dist: starlette>=0.27; python_version < '3.14'
|
| 36 |
+
Requires-Dist: starlette>=0.48.0; python_version >= '3.14'
|
| 37 |
+
Requires-Dist: typing-extensions>=4.13.0
|
| 38 |
+
Requires-Dist: typing-inspection>=0.4.1
|
| 39 |
+
Requires-Dist: uvicorn>=0.31.1; sys_platform != 'emscripten'
|
| 40 |
+
Provides-Extra: cli
|
| 41 |
+
Requires-Dist: python-dotenv>=1.0.0; extra == 'cli'
|
| 42 |
+
Requires-Dist: typer>=0.16.0; extra == 'cli'
|
| 43 |
+
Provides-Extra: rich
|
| 44 |
+
Requires-Dist: rich>=13.9.4; extra == 'rich'
|
| 45 |
+
Description-Content-Type: text/markdown
|
| 46 |
+
|
| 47 |
+
# MCP Python SDK
|
| 48 |
+
|
| 49 |
+
<div align="center">
|
| 50 |
+
|
| 51 |
+
<strong>Python implementation of the Model Context Protocol (MCP)</strong>
|
| 52 |
+
|
| 53 |
+
[![PyPI][pypi-badge]][pypi-url]
|
| 54 |
+
[![MIT licensed][mit-badge]][mit-url]
|
| 55 |
+
[![Python Version][python-badge]][python-url]
|
| 56 |
+
[![Documentation][docs-badge]][docs-url]
|
| 57 |
+
[![Protocol][protocol-badge]][protocol-url]
|
| 58 |
+
[![Specification][spec-badge]][spec-url]
|
| 59 |
+
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
> [!NOTE]
|
| 63 |
+
> **This is v2 of the MCP Python SDK, the current stable release line.** It is a major rework of the SDK, both to support the [2026-07-28 MCP specification](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) (and every earlier revision) and to fix long-standing architectural issues. Coming from v1? See [What's new in v2](https://py.sdk.modelcontextprotocol.io/whats-new/) for the tour of what changed and the [migration guide](https://py.sdk.modelcontextprotocol.io/migration/) for every breaking change.
|
| 64 |
+
>
|
| 65 |
+
> **Not ready to migrate?** v1.x lives on the [`v1.x` branch](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x), continues to receive critical bug fixes and security patches, and is documented at <https://py.sdk.modelcontextprotocol.io/v1/>. Since `pip install mcp` now installs 2.x, keep a `<2` upper bound on your requirement (for example `mcp>=1.28,<2`) until you've migrated.
|
| 66 |
+
>
|
| 67 |
+
> Something rough, confusing, or broken? [Open an issue](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml) or find us in [#python-sdk-dev on the MCP Contributors Discord](https://discord.gg/6CSzBmMkjX).
|
| 68 |
+
|
| 69 |
+
## Documentation
|
| 70 |
+
|
| 71 |
+
**The documentation lives at <https://py.sdk.modelcontextprotocol.io/>.**
|
| 72 |
+
|
| 73 |
+
It has a [Get started guide](https://py.sdk.modelcontextprotocol.io/get-started/), [What's new in v2](https://py.sdk.modelcontextprotocol.io/whats-new/), the [API reference](https://py.sdk.modelcontextprotocol.io/api/mcp/), and the [migration guide](https://py.sdk.modelcontextprotocol.io/migration/).
|
| 74 |
+
|
| 75 |
+
## What is MCP?
|
| 76 |
+
|
| 77 |
+
The [Model Context Protocol](https://modelcontextprotocol.io) lets you build servers that expose data and functionality to LLM applications in a secure, standardized way. Think of it like a web API, but designed for LLM interactions. With this SDK you can:
|
| 78 |
+
|
| 79 |
+
- **Build MCP servers** that expose tools, resources, and prompts to any MCP host
|
| 80 |
+
- **Build MCP clients** that connect to any MCP server
|
| 81 |
+
- Speak every standard transport: stdio, Streamable HTTP, and SSE
|
| 82 |
+
|
| 83 |
+
## Requirements
|
| 84 |
+
|
| 85 |
+
Python 3.10+.
|
| 86 |
+
|
| 87 |
+
## Installation
|
| 88 |
+
|
| 89 |
+
```bash
|
| 90 |
+
uv add "mcp[cli]" # or: pip install "mcp[cli]"
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
The `cli` extra adds the `mcp` command-line tool (`mcp dev`, `mcp run`, `mcp install`) on top of the SDK; install plain `mcp` if you don't need it. For one-off commands, `uv run --with "mcp[cli]" mcp ...` works without a project.
|
| 94 |
+
|
| 95 |
+
## A server in 15 lines
|
| 96 |
+
|
| 97 |
+
Create a `server.py`:
|
| 98 |
+
|
| 99 |
+
<!-- snippet-source docs_src/index/tutorial001.py -->
|
| 100 |
+
```python
|
| 101 |
+
from mcp.server import MCPServer
|
| 102 |
+
|
| 103 |
+
mcp = MCPServer("Demo")
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
@mcp.tool()
|
| 107 |
+
def add(a: int, b: int) -> int:
|
| 108 |
+
"""Add two numbers."""
|
| 109 |
+
return a + b
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
@mcp.resource("greeting://{name}")
|
| 113 |
+
def greeting(name: str) -> str:
|
| 114 |
+
"""Greet someone by name."""
|
| 115 |
+
return f"Hello, {name}!"
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
_Full example: [docs_src/index/tutorial001.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/docs_src/index/tutorial001.py)_
|
| 119 |
+
<!-- /snippet-source -->
|
| 120 |
+
|
| 121 |
+
That's a complete MCP server: one tool, one templated resource. Open it in the [MCP Inspector](https://github.com/modelcontextprotocol/inspector):
|
| 122 |
+
|
| 123 |
+
```bash
|
| 124 |
+
uv run mcp dev server.py
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
Call `add` with `a=1`, `b=2` and you get `3` back.
|
| 128 |
+
|
| 129 |
+
Notice what you did **not** write: no JSON Schema (`a: int, b: int` _is_ the schema), no request parsing, no validation code, no protocol handling. Two type-hinted Python functions and a docstring.
|
| 130 |
+
|
| 131 |
+
[Get started](https://py.sdk.modelcontextprotocol.io/get-started/) takes it from here.
|
| 132 |
+
|
| 133 |
+
## A client in 10 lines
|
| 134 |
+
|
| 135 |
+
The same package is a full MCP **client**. `Client` connects to a URL, a stdio subprocess, a custom transport, or (for tests) straight to a server object in memory with no transport at all:
|
| 136 |
+
|
| 137 |
+
```python
|
| 138 |
+
import asyncio
|
| 139 |
+
|
| 140 |
+
from mcp import Client
|
| 141 |
+
|
| 142 |
+
from server import mcp
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
async def main() -> None:
|
| 146 |
+
async with Client(mcp) as client:
|
| 147 |
+
result = await client.call_tool("add", {"a": 1, "b": 2})
|
| 148 |
+
print(result.structured_content) # {'result': 3}
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
asyncio.run(main())
|
| 152 |
+
```
|
| 153 |
+
|
| 154 |
+
Swap `mcp` for `"http://localhost:8000/mcp"` and the exact same code talks to a remote server.
|
| 155 |
+
|
| 156 |
+
## Contributing
|
| 157 |
+
|
| 158 |
+
We are passionate about supporting contributors of all levels of experience and would love to see you get involved in the project. See the [contributing guide](https://github.com/modelcontextprotocol/python-sdk/blob/main/CONTRIBUTING.md) to get started.
|
| 159 |
+
|
| 160 |
+
## License
|
| 161 |
+
|
| 162 |
+
This project is licensed under the MIT License. See the [LICENSE](https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE) file for details.
|
| 163 |
+
|
| 164 |
+
[pypi-badge]: https://img.shields.io/pypi/v/mcp.svg
|
| 165 |
+
[pypi-url]: https://pypi.org/project/mcp/
|
| 166 |
+
[mit-badge]: https://img.shields.io/pypi/l/mcp.svg
|
| 167 |
+
[mit-url]: https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE
|
| 168 |
+
[python-badge]: https://img.shields.io/pypi/pyversions/mcp.svg
|
| 169 |
+
[python-url]: https://www.python.org/downloads/
|
| 170 |
+
[docs-badge]: https://img.shields.io/badge/docs-python--sdk-blue.svg
|
| 171 |
+
[docs-url]: https://py.sdk.modelcontextprotocol.io/
|
| 172 |
+
[protocol-badge]: https://img.shields.io/badge/protocol-modelcontextprotocol.io-blue.svg
|
| 173 |
+
[protocol-url]: https://modelcontextprotocol.io
|
| 174 |
+
[spec-badge]: https://img.shields.io/badge/spec-spec.modelcontextprotocol.io-blue.svg
|
| 175 |
+
[spec-url]: https://modelcontextprotocol.io/specification/latest
|
.mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/RECORD
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
../../../bin/mcp,sha256=xBj1o--izDbW5fuime-ORyhoLLs9YnGDWjRI70FA7xA,260
|
| 2 |
+
mcp-2.0.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
| 3 |
+
mcp-2.0.0.dist-info/METADATA,sha256=dhx2va06eG7PC3x9nEiw_nvQ484IWrOIpiK95Nvugfk,7708
|
| 4 |
+
mcp-2.0.0.dist-info/RECORD,,
|
| 5 |
+
mcp-2.0.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
| 6 |
+
mcp-2.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
| 7 |
+
mcp-2.0.0.dist-info/entry_points.txt,sha256=gnGBbo3yGF0YJNXfZpgkAg1-CQtKxnzxrlA53c8viYA,42
|
| 8 |
+
mcp-2.0.0.dist-info/licenses/LICENSE,sha256=XhPbvB0SD8KgPOzefJFCSuLX3hG2PVje0vRDHiYe5Q0,1071
|
| 9 |
+
mcp/__init__.py,sha256=yYm2cScDTFl5ryD6SimPnb0DsTxSKhlUvI1ZrvsIT5o,3719
|
| 10 |
+
mcp/__pycache__/__init__.cpython-313.pyc,,
|
| 11 |
+
mcp/cli/__init__.py,sha256=CIKfYFV5bAEtsFivmJkY7NHihqKlfp-kbX3lRm6DSqE,103
|
| 12 |
+
mcp/cli/__pycache__/__init__.cpython-313.pyc,,
|
| 13 |
+
mcp/cli/__pycache__/claude.cpython-313.pyc,,
|
| 14 |
+
mcp/cli/__pycache__/cli.cpython-313.pyc,,
|
| 15 |
+
mcp/cli/claude.py,sha256=iXeCBUQMfgT-XBfwOEACQ6ENgpK64c_VIXNiT7O4sQ4,6059
|
| 16 |
+
mcp/cli/cli.py,sha256=5ynhHfRGUjb_0Fll2oCj4LzCJhmTNU5dco7XaqMrCMQ,15152
|
| 17 |
+
mcp/client/__init__.py,sha256=8WMVUrx02eQuKBXU6tD1eEbGnxRAnqTWhBeGHfBIfDk,1033
|
| 18 |
+
mcp/client/__main__.py,sha256=4siuy244dpMqsVPteG3eA4_EY0-PChS3QDteHXWYi7Y,2413
|
| 19 |
+
mcp/client/__pycache__/__init__.cpython-313.pyc,,
|
| 20 |
+
mcp/client/__pycache__/__main__.cpython-313.pyc,,
|
| 21 |
+
mcp/client/__pycache__/_input_required.cpython-313.pyc,,
|
| 22 |
+
mcp/client/__pycache__/_memory.cpython-313.pyc,,
|
| 23 |
+
mcp/client/__pycache__/_probe.cpython-313.pyc,,
|
| 24 |
+
mcp/client/__pycache__/_transport.cpython-313.pyc,,
|
| 25 |
+
mcp/client/__pycache__/caching.cpython-313.pyc,,
|
| 26 |
+
mcp/client/__pycache__/client.cpython-313.pyc,,
|
| 27 |
+
mcp/client/__pycache__/context.cpython-313.pyc,,
|
| 28 |
+
mcp/client/__pycache__/extension.cpython-313.pyc,,
|
| 29 |
+
mcp/client/__pycache__/session.cpython-313.pyc,,
|
| 30 |
+
mcp/client/__pycache__/session_group.cpython-313.pyc,,
|
| 31 |
+
mcp/client/__pycache__/sse.cpython-313.pyc,,
|
| 32 |
+
mcp/client/__pycache__/stdio.cpython-313.pyc,,
|
| 33 |
+
mcp/client/__pycache__/streamable_http.cpython-313.pyc,,
|
| 34 |
+
mcp/client/__pycache__/subscriptions.cpython-313.pyc,,
|
| 35 |
+
mcp/client/_input_required.py,sha256=sVpGcZ0tYuLff0PVWL_EIya4LQ5YOzpc2o7R8nbYgjs,5323
|
| 36 |
+
mcp/client/_memory.py,sha256=nYngTy-aDh97AvTkhMDJohAVVk8SdvakI4Jy-QhlvUM,4938
|
| 37 |
+
mcp/client/_probe.py,sha256=3F8liG_P8Ks-WaQwLVg3iXSuYp8L7td6O9rSU1FiIZI,5392
|
| 38 |
+
mcp/client/_transport.py,sha256=EyPFBefm_GgEu491SKF0Dl1NAj9uKomjXBTGdSTjnUg,695
|
| 39 |
+
mcp/client/auth/__init__.py,sha256=9plDBtxrVeOOXztjjv5JfbsDhwp-YtTQL2kCw7-0h9w,571
|
| 40 |
+
mcp/client/auth/__pycache__/__init__.cpython-313.pyc,,
|
| 41 |
+
mcp/client/auth/__pycache__/exceptions.cpython-313.pyc,,
|
| 42 |
+
mcp/client/auth/__pycache__/oauth2.cpython-313.pyc,,
|
| 43 |
+
mcp/client/auth/__pycache__/utils.cpython-313.pyc,,
|
| 44 |
+
mcp/client/auth/exceptions.py,sha256=uUDHX_P0S3R_tt7oAS8ALQrUgOiV2Dgc0QQAdaaBcW8,264
|
| 45 |
+
mcp/client/auth/extensions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
| 46 |
+
mcp/client/auth/extensions/__pycache__/__init__.cpython-313.pyc,,
|
| 47 |
+
mcp/client/auth/extensions/__pycache__/client_credentials.cpython-313.pyc,,
|
| 48 |
+
mcp/client/auth/extensions/__pycache__/identity_assertion.cpython-313.pyc,,
|
| 49 |
+
mcp/client/auth/extensions/client_credentials.py,sha256=8qDflJFw8My3BU1RDyqh7eQVePv1UKq6SSoqsMa-6Z4,13367
|
| 50 |
+
mcp/client/auth/extensions/identity_assertion.py,sha256=hRGRFe-fmwQ0Wg5TvsIRtlOwAW1Maf9rkZAZG7H1ygk,10785
|
| 51 |
+
mcp/client/auth/oauth2.py,sha256=kS9D9goCO2awXRAqXyMSr_vMtqYmZE8ZWqZznVLIcXw,38993
|
| 52 |
+
mcp/client/auth/utils.py,sha256=sdqg4jkjCveA_ufPp_OjGADursCJJ0J02Etqqe1oK1M,16547
|
| 53 |
+
mcp/client/caching.py,sha256=LI_zvXquqT9gmaH5G1gkPWHjIfdw8qATGgmHoPWc1qg,17358
|
| 54 |
+
mcp/client/client.py,sha256=JVUXkY1Im2RbKKcUxR4gDeQUET5VTCB0lmgl-3fNRUQ,43293
|
| 55 |
+
mcp/client/context.py,sha256=kC4AOFHdcrAF13DudyTSnekcxhOa7rabQkpawXbhIQc,136
|
| 56 |
+
mcp/client/extension.py,sha256=YfU1M7MYrMRSsmJJ7tw6BrNxB96MFG-38FkiWHBadZs,8428
|
| 57 |
+
mcp/client/session.py,sha256=d6bH690acrZQSK2nLn2nRN4PV9jfyzB2sRJyB5U6I5U,67109
|
| 58 |
+
mcp/client/session_group.py,sha256=pY4ONoN3WdXwfcYrnBmuHBOjxddeofQm3wsbjANbn50,17976
|
| 59 |
+
mcp/client/sse.py,sha256=PMjUSwPQk1A7UzQsK1cYWLJMnCoRELl8gBOaQNI811o,7918
|
| 60 |
+
mcp/client/stdio.py,sha256=vJh5JaPacvTKWczWJ6b8a6JYWC1QUVpTJQOr5tG526s,13827
|
| 61 |
+
mcp/client/streamable_http.py,sha256=bMc_jsfScL8wQzqFxbBaY4Sv3QjVQzaMVBh_lIQOBOU,32909
|
| 62 |
+
mcp/client/subscriptions.py,sha256=5C9MUqbY60E8nArgJmOaFgNnlaDEwfx3sECESN5Wr5Y,11563
|
| 63 |
+
mcp/os/__init__.py,sha256=uqaGIfCF4I0IPeSsEEZMNwZuDF91U0WKAIbDcKzPRLo,43
|
| 64 |
+
mcp/os/__pycache__/__init__.cpython-313.pyc,,
|
| 65 |
+
mcp/os/posix/__init__.py,sha256=l_2j8jhhgw69s_-eH4dCDk4QYxNlxKGIpaOCvcbv1Qk,40
|
| 66 |
+
mcp/os/posix/__pycache__/__init__.cpython-313.pyc,,
|
| 67 |
+
mcp/os/posix/__pycache__/utilities.cpython-313.pyc,,
|
| 68 |
+
mcp/os/posix/utilities.py,sha256=yi36QfVndZKMZs-H1aKG8KS7isQL3E5Y_7h95rkMfVc,2392
|
| 69 |
+
mcp/os/win32/__init__.py,sha256=7xyI7KWpZhDS3omGEBcu3200Q-DjIsS7c-a-vrn28Y4,42
|
| 70 |
+
mcp/os/win32/__pycache__/__init__.cpython-313.pyc,,
|
| 71 |
+
mcp/os/win32/__pycache__/utilities.cpython-313.pyc,,
|
| 72 |
+
mcp/os/win32/utilities.py,sha256=mOh8d2oUzzJuY_jaAHEZFW061kOuf78n6bbm7x3FNL8,10336
|
| 73 |
+
mcp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
| 74 |
+
mcp/server/__init__.py,sha256=jVxuDGAX-cxe4kLz2rg8WvzsURel4NeEE9g6fI6eqsY,318
|
| 75 |
+
mcp/server/__main__.py,sha256=RTnPFUeIO3xPAkEfZBUenQ0vnBcY59TPTvgOfbetVMQ,585
|
| 76 |
+
mcp/server/__pycache__/__init__.cpython-313.pyc,,
|
| 77 |
+
mcp/server/__pycache__/__main__.cpython-313.pyc,,
|
| 78 |
+
mcp/server/__pycache__/_otel.cpython-313.pyc,,
|
| 79 |
+
mcp/server/__pycache__/_streamable_http_modern.cpython-313.pyc,,
|
| 80 |
+
mcp/server/__pycache__/apps.cpython-313.pyc,,
|
| 81 |
+
mcp/server/__pycache__/caching.cpython-313.pyc,,
|
| 82 |
+
mcp/server/__pycache__/connection.cpython-313.pyc,,
|
| 83 |
+
mcp/server/__pycache__/context.cpython-313.pyc,,
|
| 84 |
+
mcp/server/__pycache__/elicitation.cpython-313.pyc,,
|
| 85 |
+
mcp/server/__pycache__/extension.cpython-313.pyc,,
|
| 86 |
+
mcp/server/__pycache__/models.cpython-313.pyc,,
|
| 87 |
+
mcp/server/__pycache__/request_state.cpython-313.pyc,,
|
| 88 |
+
mcp/server/__pycache__/runner.cpython-313.pyc,,
|
| 89 |
+
mcp/server/__pycache__/session.cpython-313.pyc,,
|
| 90 |
+
mcp/server/__pycache__/sse.cpython-313.pyc,,
|
| 91 |
+
mcp/server/__pycache__/stdio.cpython-313.pyc,,
|
| 92 |
+
mcp/server/__pycache__/streamable_http.cpython-313.pyc,,
|
| 93 |
+
mcp/server/__pycache__/streamable_http_manager.cpython-313.pyc,,
|
| 94 |
+
mcp/server/__pycache__/subscriptions.cpython-313.pyc,,
|
| 95 |
+
mcp/server/__pycache__/transport_security.cpython-313.pyc,,
|
| 96 |
+
mcp/server/__pycache__/validation.cpython-313.pyc,,
|
| 97 |
+
mcp/server/_otel.py,sha256=k71jSlD1U7uQjx0wWHkDW9W-wqUvddZIce5sNCrJjms,3359
|
| 98 |
+
mcp/server/_streamable_http_modern.py,sha256=HAsp0ZmjYJoZBugVFtCeBTZdYNrYTRbSHWK-XVAQt6g,19411
|
| 99 |
+
mcp/server/apps.py,sha256=srksMEEFBHjtyrZ2S9q7upQ1jhEiuj4fP9hm9gBPOso,9680
|
| 100 |
+
mcp/server/auth/__init__.py,sha256=_JhI3YU6YP6401tYr0L_Fi6rJhUusJTye-xZd0_xCqQ,49
|
| 101 |
+
mcp/server/auth/__pycache__/__init__.cpython-313.pyc,,
|
| 102 |
+
mcp/server/auth/__pycache__/errors.cpython-313.pyc,,
|
| 103 |
+
mcp/server/auth/__pycache__/json_response.cpython-313.pyc,,
|
| 104 |
+
mcp/server/auth/__pycache__/provider.cpython-313.pyc,,
|
| 105 |
+
mcp/server/auth/__pycache__/routes.cpython-313.pyc,,
|
| 106 |
+
mcp/server/auth/__pycache__/settings.cpython-313.pyc,,
|
| 107 |
+
mcp/server/auth/errors.py,sha256=8YjlejJPl-qMjZrbJTDzMPBOAOITn0Da0pC5QWn9QdE,224
|
| 108 |
+
mcp/server/auth/handlers/__init__.py,sha256=KuVuAHZLupFuUBp46a48h3AV9Z6Qp7_IPlxegGiW-xs,56
|
| 109 |
+
mcp/server/auth/handlers/__pycache__/__init__.cpython-313.pyc,,
|
| 110 |
+
mcp/server/auth/handlers/__pycache__/authorize.cpython-313.pyc,,
|
| 111 |
+
mcp/server/auth/handlers/__pycache__/metadata.cpython-313.pyc,,
|
| 112 |
+
mcp/server/auth/handlers/__pycache__/register.cpython-313.pyc,,
|
| 113 |
+
mcp/server/auth/handlers/__pycache__/revoke.cpython-313.pyc,,
|
| 114 |
+
mcp/server/auth/handlers/__pycache__/token.cpython-313.pyc,,
|
| 115 |
+
mcp/server/auth/handlers/authorize.py,sha256=qMcje0xGcZcgFWAV6qTG1w8hEGgaK-l90_igKOrLtvQ,9773
|
| 116 |
+
mcp/server/auth/handlers/metadata.py,sha256=mWPkrs1DfG-Qzq1nZU2HzwxSwk8K_vXe-ZpEDZ0k_QA,853
|
| 117 |
+
mcp/server/auth/handlers/register.py,sha256=f03h4UFVdYGRuUG13-7MpyO4dxbSCeXTa6tRVUE2ObQ,7179
|
| 118 |
+
mcp/server/auth/handlers/revoke.py,sha256=6z9TW1-9oJ08GlRN3prMokzQVyPdLhLaQFC_ndQYCU4,3083
|
| 119 |
+
mcp/server/auth/handlers/token.py,sha256=OCbUZW_joKh7r83gH1qwyxVSoX039jOfxX-VcFpA3Ng,12544
|
| 120 |
+
mcp/server/auth/json_response.py,sha256=EMv3qAH62vRuw8XBHdFU3kYp0S8Jd6DjRGfwc7Jc9XU,377
|
| 121 |
+
mcp/server/auth/middleware/__init__.py,sha256=hzEnzjSR61OBZBrDxL8Om3LLET20teK1HS-pqnqQEVw,40
|
| 122 |
+
mcp/server/auth/middleware/__pycache__/__init__.cpython-313.pyc,,
|
| 123 |
+
mcp/server/auth/middleware/__pycache__/auth_context.cpython-313.pyc,,
|
| 124 |
+
mcp/server/auth/middleware/__pycache__/bearer_auth.cpython-313.pyc,,
|
| 125 |
+
mcp/server/auth/middleware/__pycache__/client_auth.cpython-313.pyc,,
|
| 126 |
+
mcp/server/auth/middleware/auth_context.py,sha256=HwdfSIwEKvTfWNn1jtLTQbAYTI5JT3fPbF5MZx_k2X0,1686
|
| 127 |
+
mcp/server/auth/middleware/bearer_auth.py,sha256=fTts5E_9f5C1WYS_RqD1EEnOaDSG9VErGgRmvmmqauA,5172
|
| 128 |
+
mcp/server/auth/middleware/client_auth.py,sha256=z_sJU8Pfn3wnwKNc24zjuvPNr4Z9UlT1U7w7vyh5JLw,5019
|
| 129 |
+
mcp/server/auth/provider.py,sha256=zFxkeqHeU9KhdsauSf1SqkJ96Vjedh2rJwEZEOarWDs,14164
|
| 130 |
+
mcp/server/auth/routes.py,sha256=3Scx7RSkgAN8zVs6VjgDRS494L4VcQyQDF4BbeOGB3Y,10049
|
| 131 |
+
mcp/server/auth/settings.py,sha256=rEIZxhX0Kw69zZm42sxBfDg1vL_Dv1yn9O3ZzfFN7E0,1802
|
| 132 |
+
mcp/server/caching.py,sha256=AYLvl9PXNgPmVnsnUwp8ujbCS7My1xYuXDLTb7LUz3k,3623
|
| 133 |
+
mcp/server/connection.py,sha256=TfQplwP8FEPNb7UwHyQlZWckmEnHhvWAGgqFuUhvLaQ,21924
|
| 134 |
+
mcp/server/context.py,sha256=eWx8i0eed4d7qQb1HdB8y24xV6WAl4kjj7X8f9v-2s4,8546
|
| 135 |
+
mcp/server/elicitation.py,sha256=DTer9VWiXBIygYqIxRr45Nvw3dhK9kzoTA2D_U5Erw4,7015
|
| 136 |
+
mcp/server/extension.py,sha256=xcT7Mqo4CzEUw2EKgJItuaThqxht6Ke4jAznyJ51U5Q,8028
|
| 137 |
+
mcp/server/lowlevel/__init__.py,sha256=zUMGFpya9MVyLjNBoo7lkMGjPUDLcDsEMhbCfouxCTQ,93
|
| 138 |
+
mcp/server/lowlevel/__pycache__/__init__.cpython-313.pyc,,
|
| 139 |
+
mcp/server/lowlevel/__pycache__/helper_types.cpython-313.pyc,,
|
| 140 |
+
mcp/server/lowlevel/__pycache__/server.cpython-313.pyc,,
|
| 141 |
+
mcp/server/lowlevel/helper_types.py,sha256=LVC0N6JMPynVs4fMJSfBmqKIigHNLsRNhBvbU06rg6A,251
|
| 142 |
+
mcp/server/lowlevel/server.py,sha256=U1q8fnWzhYZxKkAw3_tOhiFBSoKsAXYavJXrtRR6Nvk,35998
|
| 143 |
+
mcp/server/mcpserver/__init__.py,sha256=qxvMcxd6rs4-ObyWAqKTiWpANnwTqPaCE7KFnqgDW-s,1340
|
| 144 |
+
mcp/server/mcpserver/__pycache__/__init__.cpython-313.pyc,,
|
| 145 |
+
mcp/server/mcpserver/__pycache__/context.cpython-313.pyc,,
|
| 146 |
+
mcp/server/mcpserver/__pycache__/exceptions.cpython-313.pyc,,
|
| 147 |
+
mcp/server/mcpserver/__pycache__/resolve.cpython-313.pyc,,
|
| 148 |
+
mcp/server/mcpserver/__pycache__/server.cpython-313.pyc,,
|
| 149 |
+
mcp/server/mcpserver/context.py,sha256=y0sV3lV49GfM2ttxSWzMf0PRlHsmhSDTcTZPznUPfkM,16418
|
| 150 |
+
mcp/server/mcpserver/exceptions.py,sha256=F7v5JqEu3NIaHGHXoDa1l7OxanhlS-s1PdFV-4bMcjc,676
|
| 151 |
+
mcp/server/mcpserver/prompts/__init__.py,sha256=4BsMxoYolpoxg74xkkkzCFL8vvdkLVJ5cIPNs1ND1Jo,99
|
| 152 |
+
mcp/server/mcpserver/prompts/__pycache__/__init__.cpython-313.pyc,,
|
| 153 |
+
mcp/server/mcpserver/prompts/__pycache__/base.cpython-313.pyc,,
|
| 154 |
+
mcp/server/mcpserver/prompts/__pycache__/manager.cpython-313.pyc,,
|
| 155 |
+
mcp/server/mcpserver/prompts/base.py,sha256=DaSnTV6K-8ctaGQpFk5XQwpKWJp3y32ceyfYfhOwUmk,7764
|
| 156 |
+
mcp/server/mcpserver/prompts/manager.py,sha256=j3fAmQCAQdbEL98A7Io1uAqwk4PcdR9rp-_ALh-ADaE,2028
|
| 157 |
+
mcp/server/mcpserver/resolve.py,sha256=5VTfUx7bIc2msbwv1eV4AMq3RbdZV6XVwHy-yL60Lcw,35408
|
| 158 |
+
mcp/server/mcpserver/resources/__init__.py,sha256=FLGQHcOjvM-7ANl0To68SXUueovIGewXpzpUWpYzhKg,618
|
| 159 |
+
mcp/server/mcpserver/resources/__pycache__/__init__.cpython-313.pyc,,
|
| 160 |
+
mcp/server/mcpserver/resources/__pycache__/base.cpython-313.pyc,,
|
| 161 |
+
mcp/server/mcpserver/resources/__pycache__/resource_manager.cpython-313.pyc,,
|
| 162 |
+
mcp/server/mcpserver/resources/__pycache__/templates.cpython-313.pyc,,
|
| 163 |
+
mcp/server/mcpserver/resources/__pycache__/types.cpython-313.pyc,,
|
| 164 |
+
mcp/server/mcpserver/resources/base.py,sha256=YkEG24pEbbsYYBTwEGX4qXeBRkpQhSkc_o6YXtY1w-0,1660
|
| 165 |
+
mcp/server/mcpserver/resources/resource_manager.py,sha256=MVuw2vciN9NLHlND_evojeD9cCabZxqArkIrTjx-T9U,5124
|
| 166 |
+
mcp/server/mcpserver/resources/templates.py,sha256=gSOVALIQrjnJG2b1JUI36ihcDG2vFjwVijnZJ-23Jb8,10090
|
| 167 |
+
mcp/server/mcpserver/resources/types.py,sha256=J7rrVY6fW8FkFXhdpsl-cKmdF67NOjOvWWKi4p1QcIc,9484
|
| 168 |
+
mcp/server/mcpserver/server.py,sha256=zE_FLRTweCaXfJoqOGcZtzJQcYbAgWAZh91Y387gVKo,55896
|
| 169 |
+
mcp/server/mcpserver/tools/__init__.py,sha256=ZboxhyMJDl87Svjov8YwNYwNZi55P9VhmpTjBZLesnk,96
|
| 170 |
+
mcp/server/mcpserver/tools/__pycache__/__init__.cpython-313.pyc,,
|
| 171 |
+
mcp/server/mcpserver/tools/__pycache__/base.cpython-313.pyc,,
|
| 172 |
+
mcp/server/mcpserver/tools/__pycache__/tool_manager.cpython-313.pyc,,
|
| 173 |
+
mcp/server/mcpserver/tools/base.py,sha256=-j7Ej17e8xOeCUZN4KmegCS_mS4CFEQ_PVqED7XLNmI,7852
|
| 174 |
+
mcp/server/mcpserver/tools/tool_manager.py,sha256=peEBf_SpzOPJOtN51XjlccMYfJJQopV1DLLhhxeQODg,2805
|
| 175 |
+
mcp/server/mcpserver/utilities/__init__.py,sha256=RU33vSfWMx0-WDN5Sowi2M9h4tchQmDizGTL9-kGPd8,33
|
| 176 |
+
mcp/server/mcpserver/utilities/__pycache__/__init__.cpython-313.pyc,,
|
| 177 |
+
mcp/server/mcpserver/utilities/__pycache__/context_injection.cpython-313.pyc,,
|
| 178 |
+
mcp/server/mcpserver/utilities/__pycache__/func_metadata.cpython-313.pyc,,
|
| 179 |
+
mcp/server/mcpserver/utilities/__pycache__/logging.cpython-313.pyc,,
|
| 180 |
+
mcp/server/mcpserver/utilities/__pycache__/types.cpython-313.pyc,,
|
| 181 |
+
mcp/server/mcpserver/utilities/context_injection.py,sha256=4pDn-HrSdxzQM4LUTa6gAV9xbBLA4KkkxzwRJo8ar60,2053
|
| 182 |
+
mcp/server/mcpserver/utilities/func_metadata.py,sha256=B90oV26YO4VLg9RyjRgu1vkGTOJngcxtcOfjR5xMBuo,25118
|
| 183 |
+
mcp/server/mcpserver/utilities/logging.py,sha256=pwvj0_Y9Eg9am3UtfEVQLUg1Lk07Iwqu_2pDK7Qtoh8,978
|
| 184 |
+
mcp/server/mcpserver/utilities/types.py,sha256=irHdBNRsBkTDnpWmSpB5abkUQu4J-1rAuvDBQlrhLP8,3430
|
| 185 |
+
mcp/server/models.py,sha256=mODTf-8u7fCzVahQh7PZZ2xvccOmZy4ZdRDrFSvB5M8,474
|
| 186 |
+
mcp/server/request_state.py,sha256=DavmX50RO4XhfjjawctsVD2rbg2K3PRckxshLqTexCk,19798
|
| 187 |
+
mcp/server/runner.py,sha256=vlXLQsvKJhwpl7tPVOKsiS-7XDWTVAnvlRrJ9ztSeyk,39549
|
| 188 |
+
mcp/server/session.py,sha256=YTLKaChdDyog0Z8ONGsR-4ZvLPSFCgmw79RN-XRSByY,20478
|
| 189 |
+
mcp/server/sse.py,sha256=pn7pLOyexuf_vAGYAdkK0ddtXBRZm3jMPWUI71clR-w,11815
|
| 190 |
+
mcp/server/stdio.py,sha256=Kj5zruVwVqBd74HLrSEEWEN96XtNoMmZMyWxlja8qew,8137
|
| 191 |
+
mcp/server/streamable_http.py,sha256=hexH1Ncnfns1AvIQaSS7pZaFVZHhss4pW4c9U_kzurs,49655
|
| 192 |
+
mcp/server/streamable_http_manager.py,sha256=LVDs_rCFLUjF3o9cL2OQTGqMp4uHQ35h8F584f0QMdU,21934
|
| 193 |
+
mcp/server/subscriptions.py,sha256=V7MIO8YUwaO7P0XRj2UCJuB2JorLHT9jdsnghYTLXZ0,11043
|
| 194 |
+
mcp/server/transport_security.py,sha256=bEH7iVU_VN3ufFfzYmM2vJvENmuaP6KKFJm3-SiO9Us,4482
|
| 195 |
+
mcp/server/validation.py,sha256=pTtwz1QK15UCZ3PSzFTGhSqiYyfQliLSQwua8kQxMUA,3665
|
| 196 |
+
mcp/shared/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
| 197 |
+
mcp/shared/__pycache__/__init__.cpython-313.pyc,,
|
| 198 |
+
mcp/shared/__pycache__/_callable_inspection.cpython-313.pyc,,
|
| 199 |
+
mcp/shared/__pycache__/_compat.cpython-313.pyc,,
|
| 200 |
+
mcp/shared/__pycache__/_context_streams.cpython-313.pyc,,
|
| 201 |
+
mcp/shared/__pycache__/_httpx_utils.cpython-313.pyc,,
|
| 202 |
+
mcp/shared/__pycache__/_otel.cpython-313.pyc,,
|
| 203 |
+
mcp/shared/__pycache__/_stream_protocols.cpython-313.pyc,,
|
| 204 |
+
mcp/shared/__pycache__/auth.cpython-313.pyc,,
|
| 205 |
+
mcp/shared/__pycache__/auth_utils.cpython-313.pyc,,
|
| 206 |
+
mcp/shared/__pycache__/context.cpython-313.pyc,,
|
| 207 |
+
mcp/shared/__pycache__/direct_dispatcher.cpython-313.pyc,,
|
| 208 |
+
mcp/shared/__pycache__/dispatcher.cpython-313.pyc,,
|
| 209 |
+
mcp/shared/__pycache__/exceptions.cpython-313.pyc,,
|
| 210 |
+
mcp/shared/__pycache__/extension.cpython-313.pyc,,
|
| 211 |
+
mcp/shared/__pycache__/inbound.cpython-313.pyc,,
|
| 212 |
+
mcp/shared/__pycache__/jsonrpc_dispatcher.cpython-313.pyc,,
|
| 213 |
+
mcp/shared/__pycache__/memory.cpython-313.pyc,,
|
| 214 |
+
mcp/shared/__pycache__/message.cpython-313.pyc,,
|
| 215 |
+
mcp/shared/__pycache__/metadata_utils.cpython-313.pyc,,
|
| 216 |
+
mcp/shared/__pycache__/path_security.cpython-313.pyc,,
|
| 217 |
+
mcp/shared/__pycache__/peer.cpython-313.pyc,,
|
| 218 |
+
mcp/shared/__pycache__/subscriptions.cpython-313.pyc,,
|
| 219 |
+
mcp/shared/__pycache__/tool_name_validation.cpython-313.pyc,,
|
| 220 |
+
mcp/shared/__pycache__/transport_context.cpython-313.pyc,,
|
| 221 |
+
mcp/shared/__pycache__/uri_template.cpython-313.pyc,,
|
| 222 |
+
mcp/shared/_callable_inspection.py,sha256=8ODRH9Bls7m_lgPqS0comKAswWRLGHLbSXXO5qaAkpY,867
|
| 223 |
+
mcp/shared/_compat.py,sha256=bzQH1crrW-VNX_qeAokeroLj4F3bqocRXRT9U271QSI,815
|
| 224 |
+
mcp/shared/_context_streams.py,sha256=4OmFYdaxKgtDduD7DnIZXX9ZQcFdo-NqHYgfuVTiejA,3759
|
| 225 |
+
mcp/shared/_httpx_utils.py,sha256=5rfKm1rC4s-qvhOvibqXOMyNYkyZGFlrrvpWm2zhR_Q,2977
|
| 226 |
+
mcp/shared/_otel.py,sha256=1PMkVOc00FvlqxmTgCw_4hDvaqWlZFhtcoGfGU0VOSE,1748
|
| 227 |
+
mcp/shared/_stream_protocols.py,sha256=8FqmXR9NN4rVrevHWnEZL0cHDe8H4ileZiJOWfJGFLc,1470
|
| 228 |
+
mcp/shared/auth.py,sha256=iZr9-k82jonWZyFBQxtqMvg5-eOKfws6bmZUzaJSAOI,12116
|
| 229 |
+
mcp/shared/auth_utils.py,sha256=aX1l1S3pDlE_9r0Fv25I76huJp1wqB25iUROb25xuuc,2881
|
| 230 |
+
mcp/shared/context.py,sha256=r4ybUzYfkt9BzOUpzBwDYPZtJf0fTEOSUxZTSwFA_fQ,3096
|
| 231 |
+
mcp/shared/direct_dispatcher.py,sha256=2FCGiL_t5Y-cK4hwc-CMGuKOqoolZHYK93VxeOiQ334,14610
|
| 232 |
+
mcp/shared/dispatcher.py,sha256=k5woCHfD8oSM_Ovb2X4A3n3QfN5Lj6tw_ePU_4sOT4Y,10892
|
| 233 |
+
mcp/shared/exceptions.py,sha256=P3hVB1g0LspGyMF89KhlnUpfPh4HSwMulA_Afg-LX64,4225
|
| 234 |
+
mcp/shared/extension.py,sha256=8Xyv-GR0ZB16GTX7mnVqZj9VTl6nfFRAt2o1gE2X_8A,1168
|
| 235 |
+
mcp/shared/inbound.py,sha256=s4zcr04IXywKYONspJdLjdZDvVVTIof4oiC-_LXwDEk,25369
|
| 236 |
+
mcp/shared/jsonrpc_dispatcher.py,sha256=gq32ZILKNr3Dzoj7WOmfd4UUlJ1T6rOBgCTvmFF4BS0,38066
|
| 237 |
+
mcp/shared/memory.py,sha256=U-L68iheegxdooC--mxIgEwXolllxVdSSJWWOp19qP4,1479
|
| 238 |
+
mcp/shared/message.py,sha256=UoeMEjzal32I1RmELn0zAj5k7TRluXdHm-JW_iBtHCk,2356
|
| 239 |
+
mcp/shared/metadata_utils.py,sha256=NOwz9Gf_PqVcNSg6RO0juzA0m3onKXnodsXuKTnxvdA,1737
|
| 240 |
+
mcp/shared/path_security.py,sha256=TFAW2v-iWK0BKMsGEeLp9LYwHc70qAlsiIsRc0Q5SNw,6210
|
| 241 |
+
mcp/shared/peer.py,sha256=F6L4dc66gzQKDnb9uIdMSRxfda796sbZKK9Ebqzh-as,9797
|
| 242 |
+
mcp/shared/subscriptions.py,sha256=mMSwdFZUpmpCydHuKgwuHb_8VVpXzn4VKwqtvG5Ye5U,4049
|
| 243 |
+
mcp/shared/tool_name_validation.py,sha256=2MQFDVgED5BMQyS0_L24as1Xf-kJNkbExeHiNnvptg8,4535
|
| 244 |
+
mcp/shared/transport_context.py,sha256=W2Mg4wlLD2b97znWAcXs9yBnl0jwhmXCxeHTxfsCkxg,1867
|
| 245 |
+
mcp/shared/uri_template.py,sha256=b3grvBhqaf4QHT1uodxEOg9aUNyOKOeAy3-F8HYXtEU,43857
|
| 246 |
+
mcp/types/__init__.py,sha256=ovVpPdU3IhzC1uzF4qaVUTRHKjg_MHy3_hBy09AhFnY,1259
|
| 247 |
+
mcp/types/__pycache__/__init__.cpython-313.pyc,,
|
| 248 |
+
mcp/types/__pycache__/jsonrpc.cpython-313.pyc,,
|
| 249 |
+
mcp/types/__pycache__/methods.cpython-313.pyc,,
|
| 250 |
+
mcp/types/__pycache__/version.cpython-313.pyc,,
|
| 251 |
+
mcp/types/jsonrpc.py,sha256=oClAyfV6VTSqu7x-xekGdT4CZxdeVUDOJ_noz10uIC0,589
|
| 252 |
+
mcp/types/methods.py,sha256=TiilvvUqJzWhRH-ngzMyFEh1Fii8dJg-f_jIWn_gA1U,572
|
| 253 |
+
mcp/types/version.py,sha256=ysQCr4rfyGeVXqZ-4KaCLbdrIwG1FPaVy6mBXOgbXUw,606
|
.mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/REQUESTED
ADDED
|
File without changes
|
.mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/WHEEL
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Wheel-Version: 1.0
|
| 2 |
+
Generator: hatchling 1.31.0
|
| 3 |
+
Root-Is-Purelib: true
|
| 4 |
+
Tag: py3-none-any
|
.mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/entry_points.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[console_scripts]
|
| 2 |
+
mcp = mcp.cli:app [cli]
|
.mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/licenses/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2024 Anthropic, PBC
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
.mcp-venv/lib/python3.13/site-packages/mcp/client/auth/extensions/client_credentials.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OAuth client credential extensions for MCP.
|
| 2 |
+
|
| 3 |
+
Provides OAuth providers for machine-to-machine authentication flows:
|
| 4 |
+
- ClientCredentialsOAuthProvider: For client_credentials with client_id + client_secret
|
| 5 |
+
- PrivateKeyJWTOAuthProvider: For client_credentials with private_key_jwt authentication
|
| 6 |
+
(typically using a pre-built JWT from workload identity federation)
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import time
|
| 10 |
+
from collections.abc import Awaitable, Callable
|
| 11 |
+
from typing import Any, Literal
|
| 12 |
+
from uuid import uuid4
|
| 13 |
+
|
| 14 |
+
import httpx2
|
| 15 |
+
import jwt
|
| 16 |
+
from pydantic import BaseModel, Field
|
| 17 |
+
|
| 18 |
+
from mcp.client.auth import OAuthClientProvider, OAuthFlowError, TokenStorage
|
| 19 |
+
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class ClientCredentialsOAuthProvider(OAuthClientProvider):
|
| 23 |
+
"""OAuth provider for client_credentials grant with client_id + client_secret.
|
| 24 |
+
|
| 25 |
+
This provider sets client_info directly, bypassing dynamic client registration.
|
| 26 |
+
Use this when you already have client credentials (client_id and client_secret).
|
| 27 |
+
|
| 28 |
+
Example:
|
| 29 |
+
```python
|
| 30 |
+
provider = ClientCredentialsOAuthProvider(
|
| 31 |
+
server_url="https://api.example.com",
|
| 32 |
+
storage=my_token_storage,
|
| 33 |
+
client_id="my-client-id",
|
| 34 |
+
client_secret="my-client-secret",
|
| 35 |
+
)
|
| 36 |
+
```
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
def __init__(
|
| 40 |
+
self,
|
| 41 |
+
server_url: str,
|
| 42 |
+
storage: TokenStorage,
|
| 43 |
+
client_id: str,
|
| 44 |
+
client_secret: str,
|
| 45 |
+
token_endpoint_auth_method: Literal["client_secret_basic", "client_secret_post"] = "client_secret_basic",
|
| 46 |
+
scope: str | None = None,
|
| 47 |
+
) -> None:
|
| 48 |
+
"""Initialize client_credentials OAuth provider.
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
server_url: The MCP server URL.
|
| 52 |
+
storage: Token storage implementation.
|
| 53 |
+
client_id: The OAuth client ID.
|
| 54 |
+
client_secret: The OAuth client secret.
|
| 55 |
+
token_endpoint_auth_method: Authentication method for token endpoint.
|
| 56 |
+
Either "client_secret_basic" (default) or "client_secret_post".
|
| 57 |
+
scope: Optional space-separated list of scopes to request.
|
| 58 |
+
"""
|
| 59 |
+
# Build minimal client_metadata for the base class
|
| 60 |
+
client_metadata = OAuthClientMetadata(
|
| 61 |
+
redirect_uris=None,
|
| 62 |
+
grant_types=["client_credentials"],
|
| 63 |
+
token_endpoint_auth_method=token_endpoint_auth_method,
|
| 64 |
+
scope=scope,
|
| 65 |
+
)
|
| 66 |
+
super().__init__(server_url, client_metadata, storage, None, None)
|
| 67 |
+
# Store client_info to be set during _initialize - no dynamic registration needed
|
| 68 |
+
self._fixed_client_info = OAuthClientInformationFull(
|
| 69 |
+
redirect_uris=None,
|
| 70 |
+
client_id=client_id,
|
| 71 |
+
client_secret=client_secret,
|
| 72 |
+
grant_types=["client_credentials"],
|
| 73 |
+
token_endpoint_auth_method=token_endpoint_auth_method,
|
| 74 |
+
scope=scope,
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
async def _initialize(self) -> None:
|
| 78 |
+
"""Load stored tokens and set pre-configured client_info."""
|
| 79 |
+
self.context.current_tokens = await self.context.storage.get_tokens()
|
| 80 |
+
self.context.client_info = self._fixed_client_info
|
| 81 |
+
self._initialized = True
|
| 82 |
+
|
| 83 |
+
async def _perform_authorization(self) -> httpx2.Request:
|
| 84 |
+
"""Perform client_credentials authorization."""
|
| 85 |
+
return await self._exchange_token_client_credentials()
|
| 86 |
+
|
| 87 |
+
async def _exchange_token_client_credentials(self) -> httpx2.Request:
|
| 88 |
+
"""Build token exchange request for client_credentials grant."""
|
| 89 |
+
token_data: dict[str, Any] = {
|
| 90 |
+
"grant_type": "client_credentials",
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
headers: dict[str, str] = {"Content-Type": "application/x-www-form-urlencoded"}
|
| 94 |
+
|
| 95 |
+
# Use standard auth methods (client_secret_basic, client_secret_post, none)
|
| 96 |
+
token_data, headers = self.context.prepare_token_auth(token_data, headers)
|
| 97 |
+
|
| 98 |
+
if self.context.should_include_resource_param(self.context.protocol_version):
|
| 99 |
+
token_data["resource"] = self.context.get_resource_url()
|
| 100 |
+
|
| 101 |
+
if self.context.client_metadata.scope:
|
| 102 |
+
token_data["scope"] = self.context.client_metadata.scope
|
| 103 |
+
|
| 104 |
+
token_url = self._get_token_endpoint()
|
| 105 |
+
return httpx2.Request("POST", token_url, data=token_data, headers=headers)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def static_assertion_provider(token: str) -> Callable[[str], Awaitable[str]]:
|
| 109 |
+
"""Create an assertion provider that returns a static JWT token.
|
| 110 |
+
|
| 111 |
+
Use this when you have a pre-built JWT (e.g., from workload identity federation)
|
| 112 |
+
that doesn't need the audience parameter.
|
| 113 |
+
|
| 114 |
+
Example:
|
| 115 |
+
```python
|
| 116 |
+
provider = PrivateKeyJWTOAuthProvider(
|
| 117 |
+
server_url="https://api.example.com",
|
| 118 |
+
storage=my_token_storage,
|
| 119 |
+
client_id="my-client-id",
|
| 120 |
+
assertion_provider=static_assertion_provider(my_prebuilt_jwt),
|
| 121 |
+
)
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
Args:
|
| 125 |
+
token: The pre-built JWT assertion string.
|
| 126 |
+
|
| 127 |
+
Returns:
|
| 128 |
+
An async callback suitable for use as an assertion_provider.
|
| 129 |
+
"""
|
| 130 |
+
|
| 131 |
+
async def provider(audience: str) -> str:
|
| 132 |
+
return token
|
| 133 |
+
|
| 134 |
+
return provider
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class SignedJWTParameters(BaseModel):
|
| 138 |
+
"""Parameters for creating SDK-signed JWT assertions.
|
| 139 |
+
|
| 140 |
+
Use `create_assertion_provider()` to create an assertion provider callback
|
| 141 |
+
for use with `PrivateKeyJWTOAuthProvider`.
|
| 142 |
+
|
| 143 |
+
Example:
|
| 144 |
+
```python
|
| 145 |
+
jwt_params = SignedJWTParameters(
|
| 146 |
+
issuer="my-client-id",
|
| 147 |
+
subject="my-client-id",
|
| 148 |
+
signing_key=private_key_pem,
|
| 149 |
+
)
|
| 150 |
+
provider = PrivateKeyJWTOAuthProvider(
|
| 151 |
+
server_url="https://api.example.com",
|
| 152 |
+
storage=my_token_storage,
|
| 153 |
+
client_id="my-client-id",
|
| 154 |
+
assertion_provider=jwt_params.create_assertion_provider(),
|
| 155 |
+
)
|
| 156 |
+
```
|
| 157 |
+
"""
|
| 158 |
+
|
| 159 |
+
issuer: str = Field(description="Issuer for JWT assertions (typically client_id).")
|
| 160 |
+
subject: str = Field(description="Subject identifier for JWT assertions (typically client_id).")
|
| 161 |
+
signing_key: str = Field(description="Private key for JWT signing (PEM format).")
|
| 162 |
+
signing_algorithm: str = Field(default="RS256", description="Algorithm for signing JWT assertions.")
|
| 163 |
+
lifetime_seconds: int = Field(default=300, description="Lifetime of generated JWT in seconds.")
|
| 164 |
+
additional_claims: dict[str, Any] | None = Field(default=None, description="Additional claims.")
|
| 165 |
+
|
| 166 |
+
def create_assertion_provider(self) -> Callable[[str], Awaitable[str]]:
|
| 167 |
+
"""Create an assertion provider callback for use with PrivateKeyJWTOAuthProvider.
|
| 168 |
+
|
| 169 |
+
Returns:
|
| 170 |
+
An async callback that takes the audience (authorization server issuer URL)
|
| 171 |
+
and returns a signed JWT assertion.
|
| 172 |
+
"""
|
| 173 |
+
|
| 174 |
+
async def provider(audience: str) -> str:
|
| 175 |
+
now = int(time.time())
|
| 176 |
+
claims: dict[str, Any] = {
|
| 177 |
+
"iss": self.issuer,
|
| 178 |
+
"sub": self.subject,
|
| 179 |
+
"aud": audience,
|
| 180 |
+
"exp": now + self.lifetime_seconds,
|
| 181 |
+
"iat": now,
|
| 182 |
+
"jti": str(uuid4()),
|
| 183 |
+
}
|
| 184 |
+
if self.additional_claims:
|
| 185 |
+
claims.update(self.additional_claims)
|
| 186 |
+
|
| 187 |
+
return jwt.encode(claims, self.signing_key, algorithm=self.signing_algorithm)
|
| 188 |
+
|
| 189 |
+
return provider
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
|
| 193 |
+
"""OAuth provider for client_credentials grant with private_key_jwt authentication.
|
| 194 |
+
|
| 195 |
+
Uses RFC 7523 Section 2.2 for client authentication via JWT assertion.
|
| 196 |
+
|
| 197 |
+
The JWT assertion's audience MUST be the authorization server's issuer identifier
|
| 198 |
+
(per RFC 7523bis security updates). The `assertion_provider` callback receives
|
| 199 |
+
this audience value and must return a JWT with that audience.
|
| 200 |
+
|
| 201 |
+
**Option 1: Pre-built JWT via Workload Identity Federation**
|
| 202 |
+
|
| 203 |
+
In production scenarios, the JWT assertion is typically obtained from a workload
|
| 204 |
+
identity provider (e.g., GCP, AWS IAM, Azure AD):
|
| 205 |
+
|
| 206 |
+
```python
|
| 207 |
+
async def get_workload_identity_token(audience: str) -> str:
|
| 208 |
+
# Fetch JWT from your identity provider
|
| 209 |
+
# The JWT's audience must match the provided audience parameter
|
| 210 |
+
return await fetch_token_from_identity_provider(audience=audience)
|
| 211 |
+
|
| 212 |
+
provider = PrivateKeyJWTOAuthProvider(
|
| 213 |
+
server_url="https://api.example.com",
|
| 214 |
+
storage=my_token_storage,
|
| 215 |
+
client_id="my-client-id",
|
| 216 |
+
assertion_provider=get_workload_identity_token,
|
| 217 |
+
)
|
| 218 |
+
```
|
| 219 |
+
|
| 220 |
+
**Option 2: Static pre-built JWT**
|
| 221 |
+
|
| 222 |
+
If you have a static JWT that doesn't need the audience parameter:
|
| 223 |
+
|
| 224 |
+
```python
|
| 225 |
+
provider = PrivateKeyJWTOAuthProvider(
|
| 226 |
+
server_url="https://api.example.com",
|
| 227 |
+
storage=my_token_storage,
|
| 228 |
+
client_id="my-client-id",
|
| 229 |
+
assertion_provider=static_assertion_provider(my_prebuilt_jwt),
|
| 230 |
+
)
|
| 231 |
+
```
|
| 232 |
+
|
| 233 |
+
**Option 3: SDK-signed JWT (for testing/simple setups)**
|
| 234 |
+
|
| 235 |
+
For testing or simple deployments, use `SignedJWTParameters.create_assertion_provider()`:
|
| 236 |
+
|
| 237 |
+
```python
|
| 238 |
+
jwt_params = SignedJWTParameters(
|
| 239 |
+
issuer="my-client-id",
|
| 240 |
+
subject="my-client-id",
|
| 241 |
+
signing_key=private_key_pem,
|
| 242 |
+
)
|
| 243 |
+
provider = PrivateKeyJWTOAuthProvider(
|
| 244 |
+
server_url="https://api.example.com",
|
| 245 |
+
storage=my_token_storage,
|
| 246 |
+
client_id="my-client-id",
|
| 247 |
+
assertion_provider=jwt_params.create_assertion_provider(),
|
| 248 |
+
)
|
| 249 |
+
```
|
| 250 |
+
"""
|
| 251 |
+
|
| 252 |
+
def __init__(
|
| 253 |
+
self,
|
| 254 |
+
server_url: str,
|
| 255 |
+
storage: TokenStorage,
|
| 256 |
+
client_id: str,
|
| 257 |
+
assertion_provider: Callable[[str], Awaitable[str]],
|
| 258 |
+
scope: str | None = None,
|
| 259 |
+
) -> None:
|
| 260 |
+
"""Initialize private_key_jwt OAuth provider.
|
| 261 |
+
|
| 262 |
+
Args:
|
| 263 |
+
server_url: The MCP server URL.
|
| 264 |
+
storage: Token storage implementation.
|
| 265 |
+
client_id: The OAuth client ID.
|
| 266 |
+
assertion_provider: Async callback that takes the audience (authorization
|
| 267 |
+
server's issuer identifier) and returns a JWT assertion. Use
|
| 268 |
+
`SignedJWTParameters.create_assertion_provider()` for SDK-signed JWTs,
|
| 269 |
+
`static_assertion_provider()` for pre-built JWTs, or provide your own
|
| 270 |
+
callback for workload identity federation.
|
| 271 |
+
scope: Optional space-separated list of scopes to request.
|
| 272 |
+
"""
|
| 273 |
+
# Build minimal client_metadata for the base class
|
| 274 |
+
client_metadata = OAuthClientMetadata(
|
| 275 |
+
redirect_uris=None,
|
| 276 |
+
grant_types=["client_credentials"],
|
| 277 |
+
token_endpoint_auth_method="private_key_jwt",
|
| 278 |
+
scope=scope,
|
| 279 |
+
)
|
| 280 |
+
super().__init__(server_url, client_metadata, storage, None, None)
|
| 281 |
+
self._assertion_provider = assertion_provider
|
| 282 |
+
# Store client_info to be set during _initialize - no dynamic registration needed
|
| 283 |
+
self._fixed_client_info = OAuthClientInformationFull(
|
| 284 |
+
redirect_uris=None,
|
| 285 |
+
client_id=client_id,
|
| 286 |
+
grant_types=["client_credentials"],
|
| 287 |
+
token_endpoint_auth_method="private_key_jwt",
|
| 288 |
+
scope=scope,
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
async def _initialize(self) -> None:
|
| 292 |
+
"""Load stored tokens and set pre-configured client_info."""
|
| 293 |
+
self.context.current_tokens = await self.context.storage.get_tokens()
|
| 294 |
+
self.context.client_info = self._fixed_client_info
|
| 295 |
+
self._initialized = True
|
| 296 |
+
|
| 297 |
+
async def _perform_authorization(self) -> httpx2.Request:
|
| 298 |
+
"""Perform client_credentials authorization with private_key_jwt."""
|
| 299 |
+
return await self._exchange_token_client_credentials()
|
| 300 |
+
|
| 301 |
+
async def _add_client_authentication_jwt(self, *, token_data: dict[str, Any]) -> None:
|
| 302 |
+
"""Add JWT assertion for client authentication to token endpoint parameters."""
|
| 303 |
+
if not self.context.oauth_metadata:
|
| 304 |
+
raise OAuthFlowError("Missing OAuth metadata for private_key_jwt flow") # pragma: no cover
|
| 305 |
+
|
| 306 |
+
# Audience MUST be the issuer identifier of the authorization server
|
| 307 |
+
# https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rfc7523bis-01
|
| 308 |
+
audience = str(self.context.oauth_metadata.issuer)
|
| 309 |
+
assertion = await self._assertion_provider(audience)
|
| 310 |
+
|
| 311 |
+
# RFC 7523 Section 2.2: client authentication via JWT
|
| 312 |
+
token_data["client_assertion"] = assertion
|
| 313 |
+
token_data["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
|
| 314 |
+
|
| 315 |
+
async def _exchange_token_client_credentials(self) -> httpx2.Request:
|
| 316 |
+
"""Build token exchange request for client_credentials grant with private_key_jwt."""
|
| 317 |
+
token_data: dict[str, Any] = {
|
| 318 |
+
"grant_type": "client_credentials",
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
headers: dict[str, str] = {"Content-Type": "application/x-www-form-urlencoded"}
|
| 322 |
+
|
| 323 |
+
# Add JWT client authentication (RFC 7523 Section 2.2)
|
| 324 |
+
await self._add_client_authentication_jwt(token_data=token_data)
|
| 325 |
+
|
| 326 |
+
if self.context.should_include_resource_param(self.context.protocol_version):
|
| 327 |
+
token_data["resource"] = self.context.get_resource_url()
|
| 328 |
+
|
| 329 |
+
if self.context.client_metadata.scope:
|
| 330 |
+
token_data["scope"] = self.context.client_metadata.scope
|
| 331 |
+
|
| 332 |
+
token_url = self._get_token_endpoint()
|
| 333 |
+
return httpx2.Request("POST", token_url, data=token_data, headers=headers)
|
.mcp-venv/lib/python3.13/site-packages/mcp/client/auth/extensions/identity_assertion.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SEP-990 Identity Assertion Authorization Grant (RFC 7523 jwt-bearer) client provider.
|
| 2 |
+
|
| 3 |
+
`IdentityAssertionOAuthProvider` is the client side of SEP-990 leg 2: it presents an Identity
|
| 4 |
+
Assertion Authorization Grant (ID-JAG) - a signed JWT issued by the enterprise identity provider -
|
| 5 |
+
to the MCP authorization server's token endpoint using the RFC 7523 jwt-bearer grant
|
| 6 |
+
(`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, ID-JAG as `assertion`), and receives an
|
| 7 |
+
MCP access token.
|
| 8 |
+
|
| 9 |
+
The authorization server is configuration, not discovery. SEP-990's trust model is the inverse of
|
| 10 |
+
the default OAuth client's: the AS issuer is supplied at construction, authorization-server metadata
|
| 11 |
+
is fetched from that issuer's own RFC 8414 well-known, and the resource server is never asked which
|
| 12 |
+
AS to use - so it cannot redirect the ID-JAG or client secret elsewhere. There is no protected
|
| 13 |
+
resource metadata fetch, no dynamic client registration, and no server-driven scope selection.
|
| 14 |
+
|
| 15 |
+
Obtaining the ID-JAG (logging into the IdP and the leg-1 token exchange against it) is
|
| 16 |
+
deployment-specific and out of scope for the SDK. The caller supplies it through the
|
| 17 |
+
`assertion_provider` callback, which receives the configured issuer (the `aud` the ID-JAG must
|
| 18 |
+
carry) and the MCP server's resource identifier (the `resource` claim it must carry, per ext-auth
|
| 19 |
+
section 4.3), and returns the ID-JAG.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
import base64
|
| 23 |
+
import time
|
| 24 |
+
from collections.abc import AsyncGenerator, Awaitable, Callable
|
| 25 |
+
from typing import Literal
|
| 26 |
+
from urllib.parse import quote, urlsplit
|
| 27 |
+
|
| 28 |
+
import anyio
|
| 29 |
+
import httpx2
|
| 30 |
+
|
| 31 |
+
from mcp.client.auth import OAuthFlowError, OAuthTokenError, TokenStorage
|
| 32 |
+
from mcp.client.auth.utils import (
|
| 33 |
+
build_oauth_authorization_server_metadata_discovery_urls,
|
| 34 |
+
create_oauth_metadata_request,
|
| 35 |
+
extract_field_from_www_auth,
|
| 36 |
+
extract_scope_from_www_auth,
|
| 37 |
+
handle_auth_metadata_response,
|
| 38 |
+
handle_token_response_scopes,
|
| 39 |
+
union_scopes,
|
| 40 |
+
validate_metadata_issuer,
|
| 41 |
+
)
|
| 42 |
+
from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken
|
| 43 |
+
from mcp.shared.auth_utils import calculate_token_expiry, resource_url_from_server_url
|
| 44 |
+
|
| 45 |
+
_DEFAULT_PORTS = {"https": 443, "http": 80}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _origin(url: str) -> tuple[str, str, int | None]:
|
| 49 |
+
"""Return the (scheme, host, port) origin of a URL for same-origin comparison.
|
| 50 |
+
|
| 51 |
+
The port is normalized to the scheme's default so an explicit `:443`/`:80` compares equal to the
|
| 52 |
+
same origin written without a port.
|
| 53 |
+
"""
|
| 54 |
+
parsed = urlsplit(url)
|
| 55 |
+
port = parsed.port if parsed.port is not None else _DEFAULT_PORTS.get(parsed.scheme)
|
| 56 |
+
return (parsed.scheme, parsed.hostname or "", port)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class IdentityAssertionOAuthProvider(httpx2.Auth):
|
| 60 |
+
"""`httpx2.Auth` for the SEP-990 ID-JAG flow (RFC 7523 jwt-bearer grant) against a configured AS.
|
| 61 |
+
|
| 62 |
+
The authorization server `issuer` is fixed at construction; metadata is fetched from its
|
| 63 |
+
RFC 8414 well-known and the ID-JAG and client secret are sent only to that issuer's token
|
| 64 |
+
endpoint. The resource server is never consulted for AS selection. The ID-JAG is fetched lazily
|
| 65 |
+
from `assertion_provider` so a fresh assertion is used on each exchange.
|
| 66 |
+
|
| 67 |
+
Example:
|
| 68 |
+
```python
|
| 69 |
+
async def fetch_id_jag(audience: str, resource: str) -> str:
|
| 70 |
+
# `audience` is the configured issuer (the ID-JAG `aud`); `resource` is the MCP
|
| 71 |
+
# server's identifier (the ID-JAG `resource` claim). Obtaining the ID-JAG from the
|
| 72 |
+
# enterprise IdP is deployment-specific and not handled by the SDK.
|
| 73 |
+
return await my_idp.issue_id_jag(audience=audience, resource=resource)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
provider = IdentityAssertionOAuthProvider(
|
| 77 |
+
server_url="https://mcp.example.com/mcp",
|
| 78 |
+
storage=my_token_storage,
|
| 79 |
+
client_id="my-client-id",
|
| 80 |
+
client_secret="my-client-secret",
|
| 81 |
+
issuer="https://auth.example.com",
|
| 82 |
+
assertion_provider=fetch_id_jag,
|
| 83 |
+
)
|
| 84 |
+
```
|
| 85 |
+
"""
|
| 86 |
+
|
| 87 |
+
requires_response_body = True
|
| 88 |
+
|
| 89 |
+
def __init__(
|
| 90 |
+
self,
|
| 91 |
+
server_url: str,
|
| 92 |
+
storage: TokenStorage,
|
| 93 |
+
client_id: str,
|
| 94 |
+
client_secret: str,
|
| 95 |
+
issuer: str,
|
| 96 |
+
assertion_provider: Callable[[str, str], Awaitable[str]],
|
| 97 |
+
scope: str | None = None,
|
| 98 |
+
token_endpoint_auth_method: Literal["client_secret_basic", "client_secret_post"] = "client_secret_post",
|
| 99 |
+
) -> None:
|
| 100 |
+
"""Initialize the identity-assertion OAuth provider.
|
| 101 |
+
|
| 102 |
+
Args:
|
| 103 |
+
server_url: The MCP server URL.
|
| 104 |
+
storage: Token storage implementation.
|
| 105 |
+
client_id: The OAuth client ID registered with the MCP authorization server.
|
| 106 |
+
client_secret: The client secret. SEP-990 section 5.1 requires a confidential client.
|
| 107 |
+
issuer: The issuer identifier of the MCP authorization server this client is provisioned
|
| 108 |
+
for. Authorization-server metadata is fetched from this issuer's well-known and the
|
| 109 |
+
ID-JAG and secret are sent only to its token endpoint.
|
| 110 |
+
assertion_provider: Async callback taking `(audience, resource)` - the configured issuer
|
| 111 |
+
and the MCP server's resource identifier - and returning the ID-JAG.
|
| 112 |
+
scope: Optional space-separated list of scopes to request.
|
| 113 |
+
token_endpoint_auth_method: Confidential-client auth method, either `client_secret_post`
|
| 114 |
+
(default) or `client_secret_basic`.
|
| 115 |
+
"""
|
| 116 |
+
if not client_secret:
|
| 117 |
+
raise ValueError("client_secret is required: SEP-990 mandates a confidential client")
|
| 118 |
+
if not issuer:
|
| 119 |
+
raise ValueError("issuer is required: the authorization server is configuration, not discovery")
|
| 120 |
+
self._resource = resource_url_from_server_url(server_url)
|
| 121 |
+
self._storage = storage
|
| 122 |
+
self._issuer = issuer
|
| 123 |
+
self._assertion_provider = assertion_provider
|
| 124 |
+
self._scope = scope
|
| 125 |
+
self._client = OAuthClientInformationFull(
|
| 126 |
+
client_id=client_id,
|
| 127 |
+
client_secret=client_secret,
|
| 128 |
+
redirect_uris=None,
|
| 129 |
+
grant_types=[JWT_BEARER_GRANT_TYPE],
|
| 130 |
+
token_endpoint_auth_method=token_endpoint_auth_method,
|
| 131 |
+
issuer=issuer,
|
| 132 |
+
)
|
| 133 |
+
self._token_endpoint: str | None = None
|
| 134 |
+
self._tokens: OAuthToken | None = None
|
| 135 |
+
self._expiry: float | None = None
|
| 136 |
+
self._lock = anyio.Lock()
|
| 137 |
+
self._initialized = False
|
| 138 |
+
|
| 139 |
+
def _build_token_request(self, scope: str | None, assertion: str) -> httpx2.Request:
|
| 140 |
+
"""Build the RFC 7523 jwt-bearer token request, applying confidential-client auth."""
|
| 141 |
+
assert self._token_endpoint is not None
|
| 142 |
+
assert self._client.client_id is not None and self._client.client_secret is not None
|
| 143 |
+
data: dict[str, str] = {
|
| 144 |
+
"grant_type": JWT_BEARER_GRANT_TYPE,
|
| 145 |
+
"assertion": assertion,
|
| 146 |
+
"client_id": self._client.client_id,
|
| 147 |
+
"resource": self._resource,
|
| 148 |
+
}
|
| 149 |
+
if scope:
|
| 150 |
+
data["scope"] = scope
|
| 151 |
+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
| 152 |
+
if self._client.token_endpoint_auth_method == "client_secret_basic":
|
| 153 |
+
# RFC 6749 section 2.3.1: URL-encode each part, then base64 the colon-joined pair.
|
| 154 |
+
encoded_id = quote(self._client.client_id, safe="")
|
| 155 |
+
encoded_secret = quote(self._client.client_secret, safe="")
|
| 156 |
+
credentials = base64.b64encode(f"{encoded_id}:{encoded_secret}".encode()).decode()
|
| 157 |
+
headers["Authorization"] = f"Basic {credentials}"
|
| 158 |
+
else:
|
| 159 |
+
data["client_secret"] = self._client.client_secret
|
| 160 |
+
return httpx2.Request("POST", self._token_endpoint, data=data, headers=headers)
|
| 161 |
+
|
| 162 |
+
async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
|
| 163 |
+
async with self._lock:
|
| 164 |
+
if not self._initialized:
|
| 165 |
+
self._tokens = await self._storage.get_tokens()
|
| 166 |
+
self._expiry = calculate_token_expiry(self._tokens.expires_in) if self._tokens else None
|
| 167 |
+
self._initialized = True
|
| 168 |
+
|
| 169 |
+
if self._tokens and (self._expiry is None or time.time() <= self._expiry):
|
| 170 |
+
request.headers["Authorization"] = f"Bearer {self._tokens.access_token}"
|
| 171 |
+
response = yield request
|
| 172 |
+
|
| 173 |
+
if response.status_code == 401:
|
| 174 |
+
scope_to_request = self._scope
|
| 175 |
+
elif response.status_code == 403 and extract_field_from_www_auth(response, "error") == "insufficient_scope":
|
| 176 |
+
scope_to_request = union_scopes(self._scope, extract_scope_from_www_auth(response))
|
| 177 |
+
else:
|
| 178 |
+
return
|
| 179 |
+
|
| 180 |
+
# Discover ASM from the configured issuer's well-known. The RS is not consulted: both
|
| 181 |
+
# arguments are the issuer, so even the helper's legacy fallback resolves there.
|
| 182 |
+
if self._token_endpoint is None:
|
| 183 |
+
for url in build_oauth_authorization_server_metadata_discovery_urls(self._issuer, self._issuer):
|
| 184 |
+
asm_response = yield create_oauth_metadata_request(url)
|
| 185 |
+
ok, asm = await handle_auth_metadata_response(asm_response)
|
| 186 |
+
if not ok:
|
| 187 |
+
break
|
| 188 |
+
if asm is not None:
|
| 189 |
+
validate_metadata_issuer(asm, self._issuer)
|
| 190 |
+
token_endpoint = str(asm.token_endpoint)
|
| 191 |
+
if _origin(token_endpoint) != _origin(self._issuer):
|
| 192 |
+
raise OAuthFlowError(
|
| 193 |
+
f"Token endpoint {token_endpoint} is not on the configured issuer origin {self._issuer}"
|
| 194 |
+
)
|
| 195 |
+
self._token_endpoint = token_endpoint
|
| 196 |
+
break
|
| 197 |
+
if self._token_endpoint is None:
|
| 198 |
+
raise OAuthFlowError(f"No authorization server metadata at configured issuer {self._issuer}")
|
| 199 |
+
|
| 200 |
+
assertion = await self._assertion_provider(self._issuer, self._resource)
|
| 201 |
+
token_response = yield self._build_token_request(scope_to_request, assertion)
|
| 202 |
+
if token_response.status_code != 200:
|
| 203 |
+
body = (await token_response.aread()).decode(errors="replace")
|
| 204 |
+
raise OAuthTokenError(f"Token exchange failed ({token_response.status_code}): {body}")
|
| 205 |
+
tokens = await handle_token_response_scopes(token_response)
|
| 206 |
+
if tokens.scope is None:
|
| 207 |
+
tokens.scope = scope_to_request
|
| 208 |
+
self._tokens = tokens
|
| 209 |
+
self._expiry = calculate_token_expiry(tokens.expires_in)
|
| 210 |
+
await self._storage.set_tokens(tokens)
|
| 211 |
+
|
| 212 |
+
request.headers["Authorization"] = f"Bearer {tokens.access_token}"
|
| 213 |
+
yield request
|
.mcp-venv/lib/python3.13/site-packages/mcp/client/auth/oauth2.py
ADDED
|
@@ -0,0 +1,785 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OAuth2 Authentication implementation for httpx2.
|
| 2 |
+
|
| 3 |
+
Implements authorization code flow with PKCE and automatic token refresh.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import base64
|
| 7 |
+
import hashlib
|
| 8 |
+
import logging
|
| 9 |
+
import secrets
|
| 10 |
+
import string
|
| 11 |
+
import time
|
| 12 |
+
from collections.abc import AsyncGenerator, Awaitable, Callable
|
| 13 |
+
from dataclasses import dataclass, field
|
| 14 |
+
from typing import Any, Protocol, get_args
|
| 15 |
+
from urllib.parse import quote, urlencode, urljoin, urlparse
|
| 16 |
+
|
| 17 |
+
import anyio
|
| 18 |
+
import httpx2
|
| 19 |
+
from mcp_types.version import is_version_at_least
|
| 20 |
+
from pydantic import BaseModel, Field, ValidationError
|
| 21 |
+
|
| 22 |
+
from mcp.client.auth.exceptions import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
|
| 23 |
+
from mcp.client.auth.utils import (
|
| 24 |
+
build_oauth_authorization_server_metadata_discovery_urls,
|
| 25 |
+
build_protected_resource_metadata_discovery_urls,
|
| 26 |
+
create_client_info_from_metadata_url,
|
| 27 |
+
create_client_registration_request,
|
| 28 |
+
create_oauth_metadata_request,
|
| 29 |
+
credentials_match_issuer,
|
| 30 |
+
extract_field_from_www_auth,
|
| 31 |
+
extract_resource_metadata_from_www_auth,
|
| 32 |
+
extract_scope_from_www_auth,
|
| 33 |
+
get_client_metadata_scopes,
|
| 34 |
+
handle_auth_metadata_response,
|
| 35 |
+
handle_protected_resource_response,
|
| 36 |
+
handle_registration_response,
|
| 37 |
+
handle_token_response_scopes,
|
| 38 |
+
is_valid_client_metadata_url,
|
| 39 |
+
should_use_client_metadata_url,
|
| 40 |
+
union_scopes,
|
| 41 |
+
validate_authorization_response_iss,
|
| 42 |
+
validate_metadata_issuer,
|
| 43 |
+
)
|
| 44 |
+
from mcp.shared.auth import (
|
| 45 |
+
AuthorizationCodeResult,
|
| 46 |
+
OAuthClientInformationFull,
|
| 47 |
+
OAuthClientMetadata,
|
| 48 |
+
OAuthMetadata,
|
| 49 |
+
OAuthToken,
|
| 50 |
+
ProtectedResourceMetadata,
|
| 51 |
+
TokenEndpointAuthMethod,
|
| 52 |
+
)
|
| 53 |
+
from mcp.shared.auth_utils import (
|
| 54 |
+
calculate_token_expiry,
|
| 55 |
+
check_resource_allowed,
|
| 56 |
+
resource_url_from_server_url,
|
| 57 |
+
)
|
| 58 |
+
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
|
| 59 |
+
|
| 60 |
+
logger = logging.getLogger(__name__)
|
| 61 |
+
|
| 62 |
+
# Methods a registered client's record may carry without a token request being an error,
|
| 63 |
+
# derived from the set the SDK is willing to request so the two cannot drift. `None`/"none"
|
| 64 |
+
# send no client secret. `private_key_jwt` sends none from here either: only
|
| 65 |
+
# `PrivateKeyJWTOAuthProvider` signs the assertion, and only in its client-credentials
|
| 66 |
+
# exchange, so its inherited refresh path must pass through here without raising - a refresh
|
| 67 |
+
# the server then rejects falls back to a fresh client-credentials exchange, which signs.
|
| 68 |
+
# Anything else is a method no client here can apply.
|
| 69 |
+
_KNOWN_TOKEN_ENDPOINT_AUTH_METHODS: tuple[str | None, ...] = (None, *get_args(TokenEndpointAuthMethod))
|
| 70 |
+
|
| 71 |
+
# Methods that authenticate the token request with the minted `client_secret`; a
|
| 72 |
+
# registration assigning one is only usable if the server issued that secret.
|
| 73 |
+
_SECRET_TOKEN_ENDPOINT_AUTH_METHODS = ("client_secret_post", "client_secret_basic")
|
| 74 |
+
|
| 75 |
+
# Methods a registration completed by the authorization-code flow can act on. That flow
|
| 76 |
+
# authenticates the token request with the minted client secret (or nothing); it holds no key
|
| 77 |
+
# to sign a `private_key_jwt` assertion, so a server assigning that method has registered a
|
| 78 |
+
# client this flow cannot use. `PrivateKeyJWTOAuthProvider` never registers dynamically.
|
| 79 |
+
_REGISTRATION_USABLE_TOKEN_ENDPOINT_AUTH_METHODS: tuple[str | None, ...] = tuple(
|
| 80 |
+
method for method in _KNOWN_TOKEN_ENDPOINT_AUTH_METHODS if method != "private_key_jwt"
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def check_registration_usable(client_info: OAuthClientInformationFull) -> None:
|
| 85 |
+
"""Confirm a registration this flow completed is one it can act on.
|
| 86 |
+
|
| 87 |
+
RFC 7591 §3.2.1 lets the authorization server replace requested metadata and leaves it to
|
| 88 |
+
the client to "check the values in the response to determine if the registration is
|
| 89 |
+
sufficient for use". Two substitutions make the minted credentials unusable, and both are
|
| 90 |
+
judged here - before the record is persisted or any interactive authorization begins -
|
| 91 |
+
rather than surfacing later as an opaque failure at the token endpoint: a token-endpoint
|
| 92 |
+
auth method the authorization-code flow cannot apply (one it does not implement, or
|
| 93 |
+
`private_key_jwt`, whose assertion this flow has no key to sign), and a secret-based
|
| 94 |
+
method the flow could apply but for which the server issued no `client_secret`.
|
| 95 |
+
|
| 96 |
+
Raises:
|
| 97 |
+
OAuthRegistrationError: The server registered the client with a
|
| 98 |
+
`token_endpoint_auth_method` this flow cannot apply, or with a secret-based
|
| 99 |
+
method but no `client_secret`.
|
| 100 |
+
"""
|
| 101 |
+
method = client_info.token_endpoint_auth_method
|
| 102 |
+
if method not in _REGISTRATION_USABLE_TOKEN_ENDPOINT_AUTH_METHODS:
|
| 103 |
+
raise OAuthRegistrationError(
|
| 104 |
+
f"Authorization server registered the client with unsupported token_endpoint_auth_method {method!r}"
|
| 105 |
+
)
|
| 106 |
+
if method in _SECRET_TOKEN_ENDPOINT_AUTH_METHODS and client_info.client_secret is None:
|
| 107 |
+
raise OAuthRegistrationError(
|
| 108 |
+
f"Authorization server registered the client for {method!r} but issued no client_secret"
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
class PKCEParameters(BaseModel):
|
| 113 |
+
"""PKCE (Proof Key for Code Exchange) parameters."""
|
| 114 |
+
|
| 115 |
+
code_verifier: str = Field(..., min_length=43, max_length=128)
|
| 116 |
+
code_challenge: str = Field(..., min_length=43, max_length=128)
|
| 117 |
+
|
| 118 |
+
@classmethod
|
| 119 |
+
def generate(cls) -> "PKCEParameters":
|
| 120 |
+
"""Generate new PKCE parameters."""
|
| 121 |
+
code_verifier = "".join(secrets.choice(string.ascii_letters + string.digits + "-._~") for _ in range(128))
|
| 122 |
+
digest = hashlib.sha256(code_verifier.encode()).digest()
|
| 123 |
+
code_challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=")
|
| 124 |
+
return cls(code_verifier=code_verifier, code_challenge=code_challenge)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
class TokenStorage(Protocol):
|
| 128 |
+
"""Protocol for token storage implementations."""
|
| 129 |
+
|
| 130 |
+
async def get_tokens(self) -> OAuthToken | None:
|
| 131 |
+
"""Get stored tokens."""
|
| 132 |
+
...
|
| 133 |
+
|
| 134 |
+
async def set_tokens(self, tokens: OAuthToken) -> None:
|
| 135 |
+
"""Store tokens."""
|
| 136 |
+
...
|
| 137 |
+
|
| 138 |
+
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
| 139 |
+
"""Get stored client information."""
|
| 140 |
+
...
|
| 141 |
+
|
| 142 |
+
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
| 143 |
+
"""Store client information."""
|
| 144 |
+
...
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
@dataclass
|
| 148 |
+
class OAuthContext:
|
| 149 |
+
"""OAuth flow context."""
|
| 150 |
+
|
| 151 |
+
server_url: str
|
| 152 |
+
client_metadata: OAuthClientMetadata
|
| 153 |
+
storage: TokenStorage
|
| 154 |
+
redirect_handler: Callable[[str], Awaitable[None]] | None
|
| 155 |
+
callback_handler: Callable[[], Awaitable[AuthorizationCodeResult]] | None
|
| 156 |
+
client_metadata_url: str | None = None
|
| 157 |
+
|
| 158 |
+
# Discovered metadata
|
| 159 |
+
protected_resource_metadata: ProtectedResourceMetadata | None = None
|
| 160 |
+
oauth_metadata: OAuthMetadata | None = None
|
| 161 |
+
auth_server_url: str | None = None
|
| 162 |
+
protocol_version: str | None = None
|
| 163 |
+
|
| 164 |
+
# Client registration
|
| 165 |
+
client_info: OAuthClientInformationFull | None = None
|
| 166 |
+
|
| 167 |
+
# Token management
|
| 168 |
+
current_tokens: OAuthToken | None = None
|
| 169 |
+
token_expiry_time: float | None = None
|
| 170 |
+
|
| 171 |
+
# State
|
| 172 |
+
lock: anyio.Lock = field(default_factory=anyio.Lock)
|
| 173 |
+
|
| 174 |
+
def get_authorization_base_url(self, server_url: str) -> str:
|
| 175 |
+
"""Extract base URL by removing path component."""
|
| 176 |
+
parsed = urlparse(server_url)
|
| 177 |
+
return f"{parsed.scheme}://{parsed.netloc}"
|
| 178 |
+
|
| 179 |
+
def update_token_expiry(self, token: OAuthToken) -> None:
|
| 180 |
+
"""Update token expiry time using shared util function."""
|
| 181 |
+
self.token_expiry_time = calculate_token_expiry(token.expires_in)
|
| 182 |
+
|
| 183 |
+
def is_token_valid(self) -> bool:
|
| 184 |
+
"""Check if current token is valid."""
|
| 185 |
+
return bool(
|
| 186 |
+
self.current_tokens
|
| 187 |
+
and self.current_tokens.access_token
|
| 188 |
+
and (not self.token_expiry_time or time.time() <= self.token_expiry_time)
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
def can_refresh_token(self) -> bool:
|
| 192 |
+
"""Check if token can be refreshed."""
|
| 193 |
+
return bool(self.current_tokens and self.current_tokens.refresh_token and self.client_info)
|
| 194 |
+
|
| 195 |
+
def clear_tokens(self) -> None:
|
| 196 |
+
"""Clear current tokens."""
|
| 197 |
+
self.current_tokens = None
|
| 198 |
+
self.token_expiry_time = None
|
| 199 |
+
|
| 200 |
+
def get_resource_url(self) -> str:
|
| 201 |
+
"""Get resource URL for RFC 8707.
|
| 202 |
+
|
| 203 |
+
Uses PRM resource if it's a valid parent, otherwise uses canonical server URL.
|
| 204 |
+
"""
|
| 205 |
+
resource = resource_url_from_server_url(self.server_url)
|
| 206 |
+
|
| 207 |
+
# If PRM provides a resource that's a valid parent, use it
|
| 208 |
+
if self.protected_resource_metadata and self.protected_resource_metadata.resource:
|
| 209 |
+
prm_resource = str(self.protected_resource_metadata.resource)
|
| 210 |
+
if check_resource_allowed(requested_resource=resource, configured_resource=prm_resource):
|
| 211 |
+
resource = prm_resource
|
| 212 |
+
|
| 213 |
+
return resource
|
| 214 |
+
|
| 215 |
+
def should_include_resource_param(self, protocol_version: str | None = None) -> bool:
|
| 216 |
+
"""Determine if the resource parameter should be included in OAuth requests.
|
| 217 |
+
|
| 218 |
+
Returns True if:
|
| 219 |
+
- Protected resource metadata is available, OR
|
| 220 |
+
- MCP-Protocol-Version header is 2025-06-18 or later
|
| 221 |
+
"""
|
| 222 |
+
# If we have protected resource metadata, include the resource param
|
| 223 |
+
if self.protected_resource_metadata is not None:
|
| 224 |
+
return True
|
| 225 |
+
|
| 226 |
+
# If no protocol version provided, don't include resource param
|
| 227 |
+
if not protocol_version:
|
| 228 |
+
return False
|
| 229 |
+
|
| 230 |
+
return is_version_at_least(protocol_version, "2025-06-18")
|
| 231 |
+
|
| 232 |
+
def prepare_token_auth(
|
| 233 |
+
self, data: dict[str, str], headers: dict[str, str] | None = None
|
| 234 |
+
) -> tuple[dict[str, str], dict[str, str]]:
|
| 235 |
+
"""Prepare authentication for token requests.
|
| 236 |
+
|
| 237 |
+
Args:
|
| 238 |
+
data: The form data to send
|
| 239 |
+
headers: Optional headers dict to update
|
| 240 |
+
|
| 241 |
+
Returns:
|
| 242 |
+
Tuple of (updated_data, updated_headers)
|
| 243 |
+
|
| 244 |
+
Raises:
|
| 245 |
+
OAuthTokenError: The client record carries a `token_endpoint_auth_method` this
|
| 246 |
+
client does not know. A dynamic registration assigning an unusable method is
|
| 247 |
+
rejected earlier, by `check_registration_usable`; this fires for a stored or
|
| 248 |
+
pre-registered record that reaches a token request with such a method.
|
| 249 |
+
"""
|
| 250 |
+
if headers is None:
|
| 251 |
+
headers = {} # pragma: no cover
|
| 252 |
+
|
| 253 |
+
if not self.client_info:
|
| 254 |
+
return data, headers
|
| 255 |
+
|
| 256 |
+
auth_method = self.client_info.token_endpoint_auth_method
|
| 257 |
+
|
| 258 |
+
if auth_method == "client_secret_basic" and self.client_info.client_secret:
|
| 259 |
+
# URL-encode client ID and secret per RFC 6749 Section 2.3.1
|
| 260 |
+
encoded_id = quote(self.client_info.client_id, safe="")
|
| 261 |
+
encoded_secret = quote(self.client_info.client_secret, safe="")
|
| 262 |
+
credentials = f"{encoded_id}:{encoded_secret}"
|
| 263 |
+
encoded_credentials = base64.b64encode(credentials.encode()).decode()
|
| 264 |
+
headers["Authorization"] = f"Basic {encoded_credentials}"
|
| 265 |
+
# Don't include client_secret in body for basic auth
|
| 266 |
+
data = {k: v for k, v in data.items() if k != "client_secret"}
|
| 267 |
+
elif auth_method == "client_secret_post" and self.client_info.client_secret:
|
| 268 |
+
# Include client_id and client_secret in request body (RFC 6749 §2.3.1)
|
| 269 |
+
data["client_id"] = self.client_info.client_id
|
| 270 |
+
data["client_secret"] = self.client_info.client_secret
|
| 271 |
+
elif auth_method not in _KNOWN_TOKEN_ENDPOINT_AUTH_METHODS:
|
| 272 |
+
raise OAuthTokenError(f"Registered client uses unsupported token_endpoint_auth_method {auth_method!r}")
|
| 273 |
+
# For "none" (or absent), don't add any client_secret; "private_key_jwt" adds its
|
| 274 |
+
# assertion in the provider that implements it, not here.
|
| 275 |
+
|
| 276 |
+
return data, headers
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
class OAuthClientProvider(httpx2.Auth):
|
| 280 |
+
"""OAuth2 authentication for httpx2.
|
| 281 |
+
|
| 282 |
+
Handles OAuth flow with automatic client registration and token storage.
|
| 283 |
+
"""
|
| 284 |
+
|
| 285 |
+
requires_response_body = True
|
| 286 |
+
|
| 287 |
+
def __init__(
|
| 288 |
+
self,
|
| 289 |
+
server_url: str,
|
| 290 |
+
client_metadata: OAuthClientMetadata,
|
| 291 |
+
storage: TokenStorage,
|
| 292 |
+
redirect_handler: Callable[[str], Awaitable[None]] | None = None,
|
| 293 |
+
callback_handler: Callable[[], Awaitable[AuthorizationCodeResult]] | None = None,
|
| 294 |
+
client_metadata_url: str | None = None,
|
| 295 |
+
validate_resource_url: Callable[[str, str | None], Awaitable[None]] | None = None,
|
| 296 |
+
):
|
| 297 |
+
"""Initialize OAuth2 authentication.
|
| 298 |
+
|
| 299 |
+
Args:
|
| 300 |
+
server_url: The MCP server URL.
|
| 301 |
+
client_metadata: OAuth client metadata for registration.
|
| 302 |
+
storage: Token storage implementation.
|
| 303 |
+
redirect_handler: Handler for authorization redirects.
|
| 304 |
+
callback_handler: Handler for authorization callbacks.
|
| 305 |
+
client_metadata_url: URL-based client ID. When provided and the server
|
| 306 |
+
advertises client_id_metadata_document_supported=True, this URL will be
|
| 307 |
+
used as the client_id instead of performing dynamic client registration.
|
| 308 |
+
Must be a valid HTTPS URL with a non-root pathname.
|
| 309 |
+
validate_resource_url: Optional callback to override resource URL validation.
|
| 310 |
+
Called with (server_url, prm_resource) where prm_resource is the resource
|
| 311 |
+
from Protected Resource Metadata (or None if not present). If not provided,
|
| 312 |
+
default validation rejects mismatched resources per RFC 8707.
|
| 313 |
+
|
| 314 |
+
Raises:
|
| 315 |
+
ValueError: If client_metadata_url is provided but not a valid HTTPS URL
|
| 316 |
+
with a non-root pathname.
|
| 317 |
+
"""
|
| 318 |
+
# Validate client_metadata_url if provided
|
| 319 |
+
if client_metadata_url is not None and not is_valid_client_metadata_url(client_metadata_url):
|
| 320 |
+
raise ValueError(
|
| 321 |
+
f"client_metadata_url must be a valid HTTPS URL with a non-root pathname, got: {client_metadata_url}"
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
self.context = OAuthContext(
|
| 325 |
+
server_url=server_url,
|
| 326 |
+
client_metadata=client_metadata,
|
| 327 |
+
storage=storage,
|
| 328 |
+
redirect_handler=redirect_handler,
|
| 329 |
+
callback_handler=callback_handler,
|
| 330 |
+
client_metadata_url=client_metadata_url,
|
| 331 |
+
)
|
| 332 |
+
self._validate_resource_url_callback = validate_resource_url
|
| 333 |
+
self._initialized = False
|
| 334 |
+
|
| 335 |
+
async def _handle_protected_resource_response(self, response: httpx2.Response) -> bool:
|
| 336 |
+
"""Handle protected resource metadata discovery response.
|
| 337 |
+
|
| 338 |
+
Per SEP-985, supports fallback when discovery fails at one URL.
|
| 339 |
+
|
| 340 |
+
Returns:
|
| 341 |
+
True if metadata was successfully discovered, False if we should try next URL
|
| 342 |
+
"""
|
| 343 |
+
if response.status_code == 200:
|
| 344 |
+
try:
|
| 345 |
+
content = await response.aread()
|
| 346 |
+
metadata = ProtectedResourceMetadata.model_validate_json(content)
|
| 347 |
+
self.context.protected_resource_metadata = metadata
|
| 348 |
+
if metadata.authorization_servers: # pragma: no branch
|
| 349 |
+
self.context.auth_server_url = str(metadata.authorization_servers[0])
|
| 350 |
+
return True
|
| 351 |
+
|
| 352 |
+
except ValidationError: # pragma: no cover
|
| 353 |
+
# Invalid metadata - try next URL
|
| 354 |
+
logger.warning(f"Invalid protected resource metadata at {response.request.url}")
|
| 355 |
+
return False
|
| 356 |
+
elif response.status_code == 404: # pragma: no cover
|
| 357 |
+
# Not found - try next URL in fallback chain
|
| 358 |
+
logger.debug(f"Protected resource metadata not found at {response.request.url}, trying next URL")
|
| 359 |
+
return False
|
| 360 |
+
else:
|
| 361 |
+
# Other error - fail immediately
|
| 362 |
+
raise OAuthFlowError(
|
| 363 |
+
f"Protected Resource Metadata request failed: {response.status_code}"
|
| 364 |
+
) # pragma: no cover
|
| 365 |
+
|
| 366 |
+
async def _perform_authorization(self) -> httpx2.Request:
|
| 367 |
+
"""Perform the authorization flow."""
|
| 368 |
+
auth_code, code_verifier = await self._perform_authorization_code_grant()
|
| 369 |
+
token_request = await self._exchange_token_authorization_code(auth_code, code_verifier)
|
| 370 |
+
return token_request
|
| 371 |
+
|
| 372 |
+
async def _perform_authorization_code_grant(self) -> tuple[str, str]:
|
| 373 |
+
"""Perform the authorization redirect and get auth code."""
|
| 374 |
+
if self.context.client_metadata.redirect_uris is None:
|
| 375 |
+
raise OAuthFlowError("No redirect URIs provided for authorization code grant") # pragma: no cover
|
| 376 |
+
if not self.context.redirect_handler:
|
| 377 |
+
raise OAuthFlowError("No redirect handler provided for authorization code grant") # pragma: no cover
|
| 378 |
+
if not self.context.callback_handler:
|
| 379 |
+
raise OAuthFlowError("No callback handler provided for authorization code grant") # pragma: no cover
|
| 380 |
+
|
| 381 |
+
if self.context.oauth_metadata and self.context.oauth_metadata.authorization_endpoint:
|
| 382 |
+
auth_endpoint = str(self.context.oauth_metadata.authorization_endpoint)
|
| 383 |
+
else:
|
| 384 |
+
auth_base_url = self.context.get_authorization_base_url(self.context.server_url)
|
| 385 |
+
auth_endpoint = urljoin(auth_base_url, "/authorize")
|
| 386 |
+
|
| 387 |
+
if not self.context.client_info:
|
| 388 |
+
raise OAuthFlowError("No client info available for authorization") # pragma: no cover
|
| 389 |
+
|
| 390 |
+
# Generate PKCE parameters
|
| 391 |
+
pkce_params = PKCEParameters.generate()
|
| 392 |
+
state = secrets.token_urlsafe(32)
|
| 393 |
+
|
| 394 |
+
auth_params = {
|
| 395 |
+
"response_type": "code",
|
| 396 |
+
"client_id": self.context.client_info.client_id,
|
| 397 |
+
"redirect_uri": str(self.context.client_metadata.redirect_uris[0]),
|
| 398 |
+
"state": state,
|
| 399 |
+
"code_challenge": pkce_params.code_challenge,
|
| 400 |
+
"code_challenge_method": "S256",
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
# Only include resource param if conditions are met
|
| 404 |
+
if self.context.should_include_resource_param(self.context.protocol_version):
|
| 405 |
+
auth_params["resource"] = self.context.get_resource_url() # RFC 8707
|
| 406 |
+
|
| 407 |
+
if self.context.client_metadata.scope: # pragma: no branch
|
| 408 |
+
auth_params["scope"] = self.context.client_metadata.scope
|
| 409 |
+
|
| 410 |
+
# OIDC requires prompt=consent when offline_access is requested
|
| 411 |
+
# https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
|
| 412 |
+
if "offline_access" in self.context.client_metadata.scope.split():
|
| 413 |
+
auth_params["prompt"] = "consent"
|
| 414 |
+
|
| 415 |
+
authorization_url = f"{auth_endpoint}?{urlencode(auth_params)}"
|
| 416 |
+
await self.context.redirect_handler(authorization_url)
|
| 417 |
+
|
| 418 |
+
# Wait for callback
|
| 419 |
+
result = await self.context.callback_handler()
|
| 420 |
+
|
| 421 |
+
if result.state is None or not secrets.compare_digest(result.state, state):
|
| 422 |
+
raise OAuthFlowError(f"State parameter mismatch: {result.state} != {state}")
|
| 423 |
+
|
| 424 |
+
# RFC 9207: validate the authorization-response issuer
|
| 425 |
+
validate_authorization_response_iss(result.iss, self.context.oauth_metadata)
|
| 426 |
+
|
| 427 |
+
if not result.code:
|
| 428 |
+
raise OAuthFlowError("No authorization code received")
|
| 429 |
+
|
| 430 |
+
# Return auth code and code verifier for token exchange
|
| 431 |
+
return result.code, pkce_params.code_verifier
|
| 432 |
+
|
| 433 |
+
def _get_token_endpoint(self) -> str:
|
| 434 |
+
if self.context.oauth_metadata and self.context.oauth_metadata.token_endpoint:
|
| 435 |
+
token_url = str(self.context.oauth_metadata.token_endpoint)
|
| 436 |
+
else:
|
| 437 |
+
auth_base_url = self.context.get_authorization_base_url(self.context.server_url)
|
| 438 |
+
token_url = urljoin(auth_base_url, "/token")
|
| 439 |
+
return token_url
|
| 440 |
+
|
| 441 |
+
async def _exchange_token_authorization_code(self, auth_code: str, code_verifier: str) -> httpx2.Request:
|
| 442 |
+
"""Build token exchange request for authorization_code flow."""
|
| 443 |
+
if self.context.client_metadata.redirect_uris is None:
|
| 444 |
+
raise OAuthFlowError("No redirect URIs provided for authorization code grant") # pragma: no cover
|
| 445 |
+
if not self.context.client_info:
|
| 446 |
+
raise OAuthFlowError("Missing client info") # pragma: no cover
|
| 447 |
+
|
| 448 |
+
token_url = self._get_token_endpoint()
|
| 449 |
+
token_data: dict[str, Any] = {
|
| 450 |
+
"grant_type": "authorization_code",
|
| 451 |
+
"code": auth_code,
|
| 452 |
+
"redirect_uri": str(self.context.client_metadata.redirect_uris[0]),
|
| 453 |
+
"client_id": self.context.client_info.client_id,
|
| 454 |
+
"code_verifier": code_verifier,
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
# Only include resource param if conditions are met
|
| 458 |
+
if self.context.should_include_resource_param(self.context.protocol_version):
|
| 459 |
+
token_data["resource"] = self.context.get_resource_url() # RFC 8707
|
| 460 |
+
|
| 461 |
+
# Prepare authentication based on preferred method
|
| 462 |
+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
| 463 |
+
token_data, headers = self.context.prepare_token_auth(token_data, headers)
|
| 464 |
+
|
| 465 |
+
return httpx2.Request("POST", token_url, data=token_data, headers=headers)
|
| 466 |
+
|
| 467 |
+
async def _handle_token_response(self, response: httpx2.Response) -> None:
|
| 468 |
+
"""Handle token exchange response."""
|
| 469 |
+
if response.status_code not in {200, 201}:
|
| 470 |
+
body = await response.aread()
|
| 471 |
+
body_text = body.decode("utf-8")
|
| 472 |
+
raise OAuthTokenError(f"Token exchange failed ({response.status_code}): {body_text}")
|
| 473 |
+
|
| 474 |
+
# Parse and validate response with scope validation
|
| 475 |
+
token_response = await handle_token_response_scopes(response)
|
| 476 |
+
|
| 477 |
+
# RFC 6749 §5.1: an omitted scope means the granted scope equals the requested
|
| 478 |
+
# scope. Record it explicitly so the persisted token is self-describing — the
|
| 479 |
+
# SEP-2350 step-up union reads it after a restart, when client_metadata.scope
|
| 480 |
+
# has reverted to its constructor value.
|
| 481 |
+
if token_response.scope is None:
|
| 482 |
+
token_response.scope = self.context.client_metadata.scope
|
| 483 |
+
|
| 484 |
+
# Store tokens in context
|
| 485 |
+
self.context.current_tokens = token_response
|
| 486 |
+
self.context.update_token_expiry(token_response)
|
| 487 |
+
await self.context.storage.set_tokens(token_response)
|
| 488 |
+
|
| 489 |
+
async def _refresh_token(self) -> httpx2.Request:
|
| 490 |
+
"""Build token refresh request."""
|
| 491 |
+
if not self.context.current_tokens or not self.context.current_tokens.refresh_token:
|
| 492 |
+
raise OAuthTokenError("No refresh token available") # pragma: no cover
|
| 493 |
+
|
| 494 |
+
if not self.context.client_info or not self.context.client_info.client_id:
|
| 495 |
+
raise OAuthTokenError("No client info available") # pragma: no cover
|
| 496 |
+
|
| 497 |
+
if self.context.oauth_metadata and self.context.oauth_metadata.token_endpoint:
|
| 498 |
+
token_url = str(self.context.oauth_metadata.token_endpoint)
|
| 499 |
+
else:
|
| 500 |
+
auth_base_url = self.context.get_authorization_base_url(self.context.server_url)
|
| 501 |
+
token_url = urljoin(auth_base_url, "/token")
|
| 502 |
+
|
| 503 |
+
refresh_data: dict[str, str] = {
|
| 504 |
+
"grant_type": "refresh_token",
|
| 505 |
+
"refresh_token": self.context.current_tokens.refresh_token,
|
| 506 |
+
"client_id": self.context.client_info.client_id,
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
# Only include resource param if conditions are met
|
| 510 |
+
if self.context.should_include_resource_param(self.context.protocol_version):
|
| 511 |
+
refresh_data["resource"] = self.context.get_resource_url() # RFC 8707
|
| 512 |
+
|
| 513 |
+
# Prepare authentication based on preferred method
|
| 514 |
+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
| 515 |
+
refresh_data, headers = self.context.prepare_token_auth(refresh_data, headers)
|
| 516 |
+
|
| 517 |
+
return httpx2.Request("POST", token_url, data=refresh_data, headers=headers)
|
| 518 |
+
|
| 519 |
+
async def _handle_refresh_response(self, response: httpx2.Response) -> bool:
|
| 520 |
+
"""Handle token refresh response. Returns True if successful."""
|
| 521 |
+
if response.status_code != 200:
|
| 522 |
+
logger.warning(f"Token refresh failed: {response.status_code}")
|
| 523 |
+
self.context.clear_tokens()
|
| 524 |
+
return False
|
| 525 |
+
|
| 526 |
+
try:
|
| 527 |
+
content = await response.aread()
|
| 528 |
+
token_response = OAuthToken.model_validate_json(content)
|
| 529 |
+
|
| 530 |
+
# RFC 6749 §6: a refresh response may omit scope (unchanged) and refresh_token
|
| 531 |
+
# (the AS does not rotate). Carry both forward so the persisted token stays
|
| 532 |
+
# self-describing for the SEP-2350 step-up union and the next expiry can
|
| 533 |
+
# still refresh instead of forcing a full re-authorization.
|
| 534 |
+
prior = self.context.current_tokens
|
| 535 |
+
if token_response.scope is None and prior is not None:
|
| 536 |
+
token_response.scope = prior.scope
|
| 537 |
+
if token_response.refresh_token is None and prior is not None:
|
| 538 |
+
token_response.refresh_token = prior.refresh_token
|
| 539 |
+
|
| 540 |
+
self.context.current_tokens = token_response
|
| 541 |
+
self.context.update_token_expiry(token_response)
|
| 542 |
+
await self.context.storage.set_tokens(token_response)
|
| 543 |
+
|
| 544 |
+
return True
|
| 545 |
+
except ValidationError: # pragma: no cover
|
| 546 |
+
logger.exception("Invalid refresh response")
|
| 547 |
+
self.context.clear_tokens()
|
| 548 |
+
return False
|
| 549 |
+
|
| 550 |
+
async def _initialize(self) -> None:
|
| 551 |
+
"""Load stored tokens and client info."""
|
| 552 |
+
self.context.current_tokens = await self.context.storage.get_tokens()
|
| 553 |
+
self.context.client_info = await self.context.storage.get_client_info()
|
| 554 |
+
self._initialized = True
|
| 555 |
+
|
| 556 |
+
def _add_auth_header(self, request: httpx2.Request) -> None:
|
| 557 |
+
"""Add authorization header to request if we have valid tokens."""
|
| 558 |
+
if self.context.current_tokens and self.context.current_tokens.access_token: # pragma: no branch
|
| 559 |
+
request.headers["Authorization"] = f"Bearer {self.context.current_tokens.access_token}"
|
| 560 |
+
|
| 561 |
+
async def _handle_oauth_metadata_response(self, response: httpx2.Response) -> None:
|
| 562 |
+
content = await response.aread()
|
| 563 |
+
metadata = OAuthMetadata.model_validate_json(content)
|
| 564 |
+
self.context.oauth_metadata = metadata
|
| 565 |
+
|
| 566 |
+
async def _validate_resource_match(self, prm: ProtectedResourceMetadata) -> None:
|
| 567 |
+
"""Validate that PRM resource matches the server URL per RFC 8707."""
|
| 568 |
+
prm_resource = str(prm.resource) if prm.resource else None
|
| 569 |
+
|
| 570 |
+
if self._validate_resource_url_callback is not None:
|
| 571 |
+
await self._validate_resource_url_callback(self.context.server_url, prm_resource)
|
| 572 |
+
return
|
| 573 |
+
|
| 574 |
+
if not prm_resource:
|
| 575 |
+
return # pragma: no cover
|
| 576 |
+
default_resource = resource_url_from_server_url(self.context.server_url)
|
| 577 |
+
if not check_resource_allowed(requested_resource=default_resource, configured_resource=prm_resource):
|
| 578 |
+
raise OAuthFlowError(f"Protected resource {prm_resource} does not match expected {default_resource}")
|
| 579 |
+
|
| 580 |
+
async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
|
| 581 |
+
"""httpx2 auth flow integration."""
|
| 582 |
+
async with self.context.lock:
|
| 583 |
+
if not self._initialized:
|
| 584 |
+
await self._initialize()
|
| 585 |
+
|
| 586 |
+
# Capture protocol version from request headers
|
| 587 |
+
self.context.protocol_version = request.headers.get(MCP_PROTOCOL_VERSION_HEADER)
|
| 588 |
+
|
| 589 |
+
if not self.context.is_token_valid() and self.context.can_refresh_token():
|
| 590 |
+
# Try to refresh token
|
| 591 |
+
refresh_request = await self._refresh_token()
|
| 592 |
+
refresh_response = yield refresh_request
|
| 593 |
+
|
| 594 |
+
if not await self._handle_refresh_response(refresh_response):
|
| 595 |
+
# Refresh failed, need full re-authentication
|
| 596 |
+
self._initialized = False
|
| 597 |
+
|
| 598 |
+
if self.context.is_token_valid():
|
| 599 |
+
self._add_auth_header(request)
|
| 600 |
+
|
| 601 |
+
response = yield request
|
| 602 |
+
|
| 603 |
+
if response.status_code == 401:
|
| 604 |
+
# Perform full OAuth flow
|
| 605 |
+
try:
|
| 606 |
+
# OAuth flow must be inline due to generator constraints
|
| 607 |
+
www_auth_resource_metadata_url = extract_resource_metadata_from_www_auth(response)
|
| 608 |
+
|
| 609 |
+
# Step 1: Discover protected resource metadata (SEP-985 with fallback support)
|
| 610 |
+
prm_discovery_urls = build_protected_resource_metadata_discovery_urls(
|
| 611 |
+
www_auth_resource_metadata_url, self.context.server_url
|
| 612 |
+
)
|
| 613 |
+
|
| 614 |
+
for url in prm_discovery_urls: # pragma: no branch
|
| 615 |
+
discovery_request = create_oauth_metadata_request(url)
|
| 616 |
+
|
| 617 |
+
discovery_response = yield discovery_request # sending request
|
| 618 |
+
|
| 619 |
+
prm = await handle_protected_resource_response(discovery_response)
|
| 620 |
+
if prm:
|
| 621 |
+
# Validate PRM resource matches server URL (RFC 8707)
|
| 622 |
+
await self._validate_resource_match(prm)
|
| 623 |
+
self.context.protected_resource_metadata = prm
|
| 624 |
+
|
| 625 |
+
# todo: try all authorization_servers to find the OASM
|
| 626 |
+
assert (
|
| 627 |
+
len(prm.authorization_servers) > 0
|
| 628 |
+
) # this is always true as authorization_servers has a min length of 1
|
| 629 |
+
|
| 630 |
+
self.context.auth_server_url = str(prm.authorization_servers[0])
|
| 631 |
+
break
|
| 632 |
+
else:
|
| 633 |
+
logger.debug(f"Protected resource metadata discovery failed: {url}")
|
| 634 |
+
|
| 635 |
+
# SEP-2352: stored credentials are bound to the issuer that registered them.
|
| 636 |
+
# If the authorization server changed, drop them (and the old tokens) so the
|
| 637 |
+
# flow re-registers instead of presenting another server's credentials.
|
| 638 |
+
if (
|
| 639 |
+
self.context.client_info is not None
|
| 640 |
+
and self.context.auth_server_url is not None
|
| 641 |
+
and not credentials_match_issuer(
|
| 642 |
+
self.context.client_info, self.context.auth_server_url, self.context.client_metadata_url
|
| 643 |
+
)
|
| 644 |
+
):
|
| 645 |
+
logger.debug("Authorization server changed; discarding bound credentials and re-registering")
|
| 646 |
+
self.context.client_info = None
|
| 647 |
+
self.context.clear_tokens()
|
| 648 |
+
# Any cached AS metadata is for the old server; drop it so a failed
|
| 649 |
+
# rediscovery cannot leak the old registration/token endpoints into Step 4.
|
| 650 |
+
self.context.oauth_metadata = None
|
| 651 |
+
|
| 652 |
+
asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls(
|
| 653 |
+
self.context.auth_server_url, self.context.server_url
|
| 654 |
+
)
|
| 655 |
+
|
| 656 |
+
# Step 2: Discover OAuth Authorization Server Metadata (OASM) (with fallback for legacy servers)
|
| 657 |
+
for url in asm_discovery_urls: # pragma: no branch
|
| 658 |
+
oauth_metadata_request = create_oauth_metadata_request(url)
|
| 659 |
+
oauth_metadata_response = yield oauth_metadata_request
|
| 660 |
+
|
| 661 |
+
ok, asm = await handle_auth_metadata_response(oauth_metadata_response)
|
| 662 |
+
if not ok:
|
| 663 |
+
break
|
| 664 |
+
if ok and asm:
|
| 665 |
+
# SEP-2468: metadata issuer must match the discovery issuer
|
| 666 |
+
if self.context.auth_server_url is not None:
|
| 667 |
+
validate_metadata_issuer(asm, self.context.auth_server_url)
|
| 668 |
+
self.context.oauth_metadata = asm
|
| 669 |
+
break
|
| 670 |
+
else:
|
| 671 |
+
logger.debug(f"OAuth metadata discovery failed: {url}")
|
| 672 |
+
|
| 673 |
+
# SEP-2352: on the legacy no-PRM path the issuer is only known after ASM
|
| 674 |
+
# discovery, so re-evaluate the binding here using the discovered metadata
|
| 675 |
+
# issuer (mirroring the bound_issuer fallback in Step 4).
|
| 676 |
+
if (
|
| 677 |
+
self.context.client_info is not None
|
| 678 |
+
and self.context.auth_server_url is None
|
| 679 |
+
and self.context.oauth_metadata is not None
|
| 680 |
+
and not credentials_match_issuer(
|
| 681 |
+
self.context.client_info,
|
| 682 |
+
str(self.context.oauth_metadata.issuer),
|
| 683 |
+
self.context.client_metadata_url,
|
| 684 |
+
)
|
| 685 |
+
):
|
| 686 |
+
logger.debug("Authorization server changed; discarding bound credentials and re-registering")
|
| 687 |
+
self.context.client_info = None
|
| 688 |
+
self.context.clear_tokens()
|
| 689 |
+
|
| 690 |
+
# Step 3: Apply scope selection strategy
|
| 691 |
+
self.context.client_metadata.scope = get_client_metadata_scopes(
|
| 692 |
+
extract_scope_from_www_auth(response),
|
| 693 |
+
self.context.protected_resource_metadata,
|
| 694 |
+
self.context.oauth_metadata,
|
| 695 |
+
self.context.client_metadata.grant_types,
|
| 696 |
+
)
|
| 697 |
+
|
| 698 |
+
# Step 4: Register client or use URL-based client ID (CIMD)
|
| 699 |
+
if not self.context.client_info:
|
| 700 |
+
# SEP-2352: the issuer to bind these credentials to, when known.
|
| 701 |
+
discovered_issuer: str | None = None
|
| 702 |
+
if self.context.oauth_metadata is not None:
|
| 703 |
+
discovered_issuer = self.context.auth_server_url or str(self.context.oauth_metadata.issuer)
|
| 704 |
+
|
| 705 |
+
if should_use_client_metadata_url(
|
| 706 |
+
self.context.oauth_metadata, self.context.client_metadata_url
|
| 707 |
+
):
|
| 708 |
+
# Use URL-based client ID (CIMD). CIMD records are portable across
|
| 709 |
+
# authorization servers, so the issuer stamp is informational.
|
| 710 |
+
logger.debug(f"Using URL-based client ID (CIMD): {self.context.client_metadata_url}")
|
| 711 |
+
client_information = create_client_info_from_metadata_url(
|
| 712 |
+
self.context.client_metadata_url, # type: ignore[arg-type]
|
| 713 |
+
redirect_uris=self.context.client_metadata.redirect_uris,
|
| 714 |
+
)
|
| 715 |
+
client_information.issuer = discovered_issuer
|
| 716 |
+
self.context.client_info = client_information
|
| 717 |
+
await self.context.storage.set_client_info(client_information)
|
| 718 |
+
else:
|
| 719 |
+
# Fallback to Dynamic Client Registration
|
| 720 |
+
fallback_base = self.context.get_authorization_base_url(self.context.server_url)
|
| 721 |
+
registration_request = create_client_registration_request(
|
| 722 |
+
self.context.oauth_metadata, self.context.client_metadata, fallback_base
|
| 723 |
+
)
|
| 724 |
+
registration_response = yield registration_request
|
| 725 |
+
client_information = await handle_registration_response(registration_response)
|
| 726 |
+
check_registration_usable(client_information)
|
| 727 |
+
# Only record the issuer when the registration above actually targeted
|
| 728 |
+
# the discovered AS — either via its published registration_endpoint,
|
| 729 |
+
# or because the resource-origin /register fallback is on the issuer's
|
| 730 |
+
# own host (legacy same-origin embedded AS). Otherwise the fallback hit
|
| 731 |
+
# a different server and recording a binding to the PRM-advertised AS
|
| 732 |
+
# would persist a binding that was never established.
|
| 733 |
+
if (
|
| 734 |
+
self.context.oauth_metadata is not None
|
| 735 |
+
and discovered_issuer is not None
|
| 736 |
+
and (
|
| 737 |
+
self.context.oauth_metadata.registration_endpoint is not None
|
| 738 |
+
or self.context.get_authorization_base_url(discovered_issuer) == fallback_base
|
| 739 |
+
)
|
| 740 |
+
):
|
| 741 |
+
client_information.issuer = discovered_issuer
|
| 742 |
+
self.context.client_info = client_information
|
| 743 |
+
await self.context.storage.set_client_info(client_information)
|
| 744 |
+
|
| 745 |
+
# Step 5: Perform authorization and complete token exchange
|
| 746 |
+
token_response = yield await self._perform_authorization()
|
| 747 |
+
await self._handle_token_response(token_response)
|
| 748 |
+
except Exception:
|
| 749 |
+
logger.exception("OAuth flow error")
|
| 750 |
+
raise
|
| 751 |
+
|
| 752 |
+
# Retry with new tokens
|
| 753 |
+
self._add_auth_header(request)
|
| 754 |
+
yield request
|
| 755 |
+
elif response.status_code == 403:
|
| 756 |
+
# Step 1: Extract error field from WWW-Authenticate header
|
| 757 |
+
error = extract_field_from_www_auth(response, "error")
|
| 758 |
+
|
| 759 |
+
# Step 2: Check if we need to step-up authorization
|
| 760 |
+
if error == "insufficient_scope": # pragma: no branch
|
| 761 |
+
try:
|
| 762 |
+
# Step 2a: Union previously requested scopes with the newly challenged
|
| 763 |
+
# scopes (SEP-2350) so escalating one operation keeps the others' grants.
|
| 764 |
+
# Fold in the stored token's scope too: on a restart the token is reloaded
|
| 765 |
+
# but client_metadata.scope is not, so it would otherwise be the only basis.
|
| 766 |
+
challenged_scope = get_client_metadata_scopes(
|
| 767 |
+
extract_scope_from_www_auth(response),
|
| 768 |
+
self.context.protected_resource_metadata,
|
| 769 |
+
self.context.oauth_metadata,
|
| 770 |
+
self.context.client_metadata.grant_types,
|
| 771 |
+
)
|
| 772 |
+
granted_scope = self.context.current_tokens.scope if self.context.current_tokens else None
|
| 773 |
+
prior_scope = union_scopes(self.context.client_metadata.scope, granted_scope)
|
| 774 |
+
self.context.client_metadata.scope = union_scopes(prior_scope, challenged_scope)
|
| 775 |
+
|
| 776 |
+
# Step 2b: Perform (re-)authorization and token exchange
|
| 777 |
+
token_response = yield await self._perform_authorization()
|
| 778 |
+
await self._handle_token_response(token_response)
|
| 779 |
+
except Exception: # pragma: no cover
|
| 780 |
+
logger.exception("OAuth flow error")
|
| 781 |
+
raise
|
| 782 |
+
|
| 783 |
+
# Retry with new tokens
|
| 784 |
+
self._add_auth_header(request)
|
| 785 |
+
yield request
|
.mcp-venv/lib/python3.13/site-packages/mcp/client/auth/utils.py
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from typing import Any, cast
|
| 3 |
+
from urllib.parse import urljoin, urlparse
|
| 4 |
+
|
| 5 |
+
from httpx2 import Request, Response
|
| 6 |
+
from mcp_types import LATEST_PROTOCOL_VERSION
|
| 7 |
+
from pydantic import AnyUrl, ValidationError
|
| 8 |
+
from pydantic_core import from_json
|
| 9 |
+
|
| 10 |
+
from mcp.client.auth import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
|
| 11 |
+
from mcp.shared.auth import (
|
| 12 |
+
OAuthClientInformationFull,
|
| 13 |
+
OAuthClientMetadata,
|
| 14 |
+
OAuthMetadata,
|
| 15 |
+
OAuthToken,
|
| 16 |
+
ProtectedResourceMetadata,
|
| 17 |
+
)
|
| 18 |
+
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def extract_field_from_www_auth(response: Response, field_name: str) -> str | None:
|
| 22 |
+
"""Extract field from WWW-Authenticate header.
|
| 23 |
+
|
| 24 |
+
Returns:
|
| 25 |
+
Field value if found in WWW-Authenticate header, None otherwise
|
| 26 |
+
"""
|
| 27 |
+
www_auth_header = response.headers.get("WWW-Authenticate")
|
| 28 |
+
if not www_auth_header:
|
| 29 |
+
return None
|
| 30 |
+
|
| 31 |
+
# Pattern matches: field_name="value" or field_name=value (unquoted)
|
| 32 |
+
pattern = rf'{field_name}=(?:"([^"]+)"|([^\s,]+))'
|
| 33 |
+
match = re.search(pattern, www_auth_header)
|
| 34 |
+
|
| 35 |
+
if match:
|
| 36 |
+
# Return quoted value if present, otherwise unquoted value
|
| 37 |
+
return match.group(1) or match.group(2)
|
| 38 |
+
|
| 39 |
+
return None
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def extract_scope_from_www_auth(response: Response) -> str | None:
|
| 43 |
+
"""Extract scope parameter from WWW-Authenticate header as per RFC 6750.
|
| 44 |
+
|
| 45 |
+
Returns:
|
| 46 |
+
Scope string if found in WWW-Authenticate header, None otherwise
|
| 47 |
+
"""
|
| 48 |
+
return extract_field_from_www_auth(response, "scope")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def extract_resource_metadata_from_www_auth(response: Response) -> str | None:
|
| 52 |
+
"""Extract protected resource metadata URL from WWW-Authenticate header as per RFC 9728.
|
| 53 |
+
|
| 54 |
+
Returns:
|
| 55 |
+
Resource metadata URL if found in WWW-Authenticate header, None otherwise
|
| 56 |
+
"""
|
| 57 |
+
if not response or response.status_code != 401:
|
| 58 |
+
return None # pragma: no cover
|
| 59 |
+
|
| 60 |
+
return extract_field_from_www_auth(response, "resource_metadata")
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def build_protected_resource_metadata_discovery_urls(www_auth_url: str | None, server_url: str) -> list[str]:
|
| 64 |
+
"""Build ordered list of URLs to try for protected resource metadata discovery.
|
| 65 |
+
|
| 66 |
+
Per SEP-985, the client MUST:
|
| 67 |
+
1. Try resource_metadata from WWW-Authenticate header (if present)
|
| 68 |
+
2. Fall back to path-based well-known URI: /.well-known/oauth-protected-resource/{path}
|
| 69 |
+
3. Fall back to root-based well-known URI: /.well-known/oauth-protected-resource
|
| 70 |
+
|
| 71 |
+
Args:
|
| 72 |
+
www_auth_url: Optional resource_metadata URL extracted from the WWW-Authenticate header
|
| 73 |
+
server_url: Server URL
|
| 74 |
+
|
| 75 |
+
Returns:
|
| 76 |
+
Ordered list of URLs to try for discovery
|
| 77 |
+
"""
|
| 78 |
+
urls: list[str] = []
|
| 79 |
+
|
| 80 |
+
# Priority 1: WWW-Authenticate header with resource_metadata parameter
|
| 81 |
+
if www_auth_url:
|
| 82 |
+
urls.append(www_auth_url)
|
| 83 |
+
|
| 84 |
+
# Priority 2-3: Well-known URIs (RFC 9728)
|
| 85 |
+
parsed = urlparse(server_url)
|
| 86 |
+
base_url = f"{parsed.scheme}://{parsed.netloc}"
|
| 87 |
+
|
| 88 |
+
# Priority 2: Path-based well-known URI (if server has a path component)
|
| 89 |
+
if parsed.path and parsed.path != "/":
|
| 90 |
+
path_based_url = urljoin(base_url, f"/.well-known/oauth-protected-resource{parsed.path}")
|
| 91 |
+
urls.append(path_based_url)
|
| 92 |
+
|
| 93 |
+
# Priority 3: Root-based well-known URI
|
| 94 |
+
root_based_url = urljoin(base_url, "/.well-known/oauth-protected-resource")
|
| 95 |
+
urls.append(root_based_url)
|
| 96 |
+
|
| 97 |
+
return urls
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def get_client_metadata_scopes(
|
| 101 |
+
www_authenticate_scope: str | None,
|
| 102 |
+
protected_resource_metadata: ProtectedResourceMetadata | None,
|
| 103 |
+
authorization_server_metadata: OAuthMetadata | None = None,
|
| 104 |
+
client_grant_types: list[str] | None = None,
|
| 105 |
+
) -> str | None:
|
| 106 |
+
"""Select effective scopes and augment for refresh token support."""
|
| 107 |
+
selected_scope: str | None = None
|
| 108 |
+
|
| 109 |
+
# MCP spec scope selection priority:
|
| 110 |
+
# 1. WWW-Authenticate header scope
|
| 111 |
+
# 2. PRM scopes_supported
|
| 112 |
+
# 3. AS scopes_supported (SDK fallback)
|
| 113 |
+
# 4. Omit scope parameter
|
| 114 |
+
if www_authenticate_scope is not None:
|
| 115 |
+
selected_scope = www_authenticate_scope
|
| 116 |
+
elif protected_resource_metadata is not None and protected_resource_metadata.scopes_supported is not None:
|
| 117 |
+
selected_scope = " ".join(protected_resource_metadata.scopes_supported)
|
| 118 |
+
elif authorization_server_metadata is not None and authorization_server_metadata.scopes_supported is not None:
|
| 119 |
+
selected_scope = " ".join(authorization_server_metadata.scopes_supported)
|
| 120 |
+
|
| 121 |
+
# SEP-2207: append offline_access when the AS supports it and the client can use refresh tokens
|
| 122 |
+
if (
|
| 123 |
+
selected_scope is not None
|
| 124 |
+
and authorization_server_metadata is not None
|
| 125 |
+
and authorization_server_metadata.scopes_supported is not None
|
| 126 |
+
and "offline_access" in authorization_server_metadata.scopes_supported
|
| 127 |
+
and client_grant_types is not None
|
| 128 |
+
and "refresh_token" in client_grant_types
|
| 129 |
+
and "offline_access" not in selected_scope.split()
|
| 130 |
+
):
|
| 131 |
+
selected_scope = f"{selected_scope} offline_access"
|
| 132 |
+
|
| 133 |
+
return selected_scope
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def union_scopes(previous_scope: str | None, new_scope: str | None) -> str | None:
|
| 137 |
+
"""Merge two space-delimited scope strings, preserving order and dropping duplicates.
|
| 138 |
+
|
| 139 |
+
SEP-2350: on step-up re-authorization the client requests the union of previously requested
|
| 140 |
+
scopes and the newly challenged scopes, so escalating one operation does not drop the
|
| 141 |
+
permissions granted for another. Previously requested scopes come first; new scopes are
|
| 142 |
+
appended in order.
|
| 143 |
+
"""
|
| 144 |
+
if not previous_scope:
|
| 145 |
+
return new_scope
|
| 146 |
+
if not new_scope:
|
| 147 |
+
return previous_scope
|
| 148 |
+
|
| 149 |
+
merged = previous_scope.split()
|
| 150 |
+
seen = set(merged)
|
| 151 |
+
for scope in new_scope.split():
|
| 152 |
+
if scope not in seen:
|
| 153 |
+
merged.append(scope)
|
| 154 |
+
seen.add(scope)
|
| 155 |
+
return " ".join(merged)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def build_oauth_authorization_server_metadata_discovery_urls(auth_server_url: str | None, server_url: str) -> list[str]:
|
| 159 |
+
"""Generate an ordered list of URLs for authorization server metadata discovery.
|
| 160 |
+
|
| 161 |
+
Args:
|
| 162 |
+
auth_server_url: OAuth Authorization Server Metadata URL if found, otherwise None
|
| 163 |
+
server_url: URL for the MCP server, used as a fallback if auth_server_url is None
|
| 164 |
+
"""
|
| 165 |
+
|
| 166 |
+
if not auth_server_url:
|
| 167 |
+
# Legacy path using the 2025-03-26 spec:
|
| 168 |
+
# link: https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization
|
| 169 |
+
parsed = urlparse(server_url)
|
| 170 |
+
return [f"{parsed.scheme}://{parsed.netloc}/.well-known/oauth-authorization-server"]
|
| 171 |
+
|
| 172 |
+
urls: list[str] = []
|
| 173 |
+
parsed = urlparse(auth_server_url)
|
| 174 |
+
base_url = f"{parsed.scheme}://{parsed.netloc}"
|
| 175 |
+
|
| 176 |
+
# RFC 8414: Path-aware OAuth discovery
|
| 177 |
+
if parsed.path and parsed.path != "/":
|
| 178 |
+
oauth_path = f"/.well-known/oauth-authorization-server{parsed.path.rstrip('/')}"
|
| 179 |
+
urls.append(urljoin(base_url, oauth_path))
|
| 180 |
+
|
| 181 |
+
# RFC 8414 section 5: Path-aware OIDC discovery
|
| 182 |
+
# See https://www.rfc-editor.org/rfc/rfc8414.html#section-5
|
| 183 |
+
oidc_path = f"/.well-known/openid-configuration{parsed.path.rstrip('/')}"
|
| 184 |
+
urls.append(urljoin(base_url, oidc_path))
|
| 185 |
+
|
| 186 |
+
# https://openid.net/specs/openid-connect-discovery-1_0.html
|
| 187 |
+
oidc_path = f"{parsed.path.rstrip('/')}/.well-known/openid-configuration"
|
| 188 |
+
urls.append(urljoin(base_url, oidc_path))
|
| 189 |
+
return urls
|
| 190 |
+
|
| 191 |
+
# OAuth root
|
| 192 |
+
urls.append(urljoin(base_url, "/.well-known/oauth-authorization-server"))
|
| 193 |
+
|
| 194 |
+
# OIDC 1.0 fallback (appends to full URL per OIDC spec)
|
| 195 |
+
# https://openid.net/specs/openid-connect-discovery-1_0.html
|
| 196 |
+
urls.append(urljoin(base_url, "/.well-known/openid-configuration"))
|
| 197 |
+
|
| 198 |
+
return urls
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
async def handle_protected_resource_response(
|
| 202 |
+
response: Response,
|
| 203 |
+
) -> ProtectedResourceMetadata | None:
|
| 204 |
+
"""Handle protected resource metadata discovery response.
|
| 205 |
+
|
| 206 |
+
Per SEP-985, supports fallback when discovery fails at one URL.
|
| 207 |
+
|
| 208 |
+
Returns:
|
| 209 |
+
ProtectedResourceMetadata if successfully discovered, None if we should try next URL
|
| 210 |
+
"""
|
| 211 |
+
if response.status_code == 200:
|
| 212 |
+
try:
|
| 213 |
+
content = await response.aread()
|
| 214 |
+
metadata = ProtectedResourceMetadata.model_validate_json(content)
|
| 215 |
+
return metadata
|
| 216 |
+
|
| 217 |
+
except ValidationError: # pragma: no cover
|
| 218 |
+
# Invalid metadata - try next URL
|
| 219 |
+
return None
|
| 220 |
+
else:
|
| 221 |
+
# Not found - try next URL in fallback chain
|
| 222 |
+
return None
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
async def handle_auth_metadata_response(response: Response) -> tuple[bool, OAuthMetadata | None]:
|
| 226 |
+
if response.status_code == 200:
|
| 227 |
+
try:
|
| 228 |
+
content = await response.aread()
|
| 229 |
+
asm = OAuthMetadata.model_validate_json(content)
|
| 230 |
+
return True, asm
|
| 231 |
+
except ValidationError: # pragma: no cover
|
| 232 |
+
return True, None
|
| 233 |
+
elif response.status_code < 400 or response.status_code >= 500:
|
| 234 |
+
return False, None # Non-4XX error, stop trying
|
| 235 |
+
return True, None
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def validate_authorization_response_iss(iss: str | None, oauth_metadata: OAuthMetadata | None) -> None:
|
| 239 |
+
"""Validate the RFC 9207 `iss` authorization-response parameter.
|
| 240 |
+
|
| 241 |
+
Per RFC 9207 section 2.4, the client compares `iss` against the issuer of the
|
| 242 |
+
authorization server the request was sent to, using simple string comparison
|
| 243 |
+
(RFC 3986 section 6.2.1, i.e. without URL normalization), and rejects on mismatch.
|
| 244 |
+
A response that omits `iss` is rejected only when the server advertised support via
|
| 245 |
+
`authorization_response_iss_parameter_supported`.
|
| 246 |
+
|
| 247 |
+
Raises:
|
| 248 |
+
OAuthFlowError: If `iss` is present and does not match, or is absent when the
|
| 249 |
+
authorization server advertised support.
|
| 250 |
+
"""
|
| 251 |
+
expected = str(oauth_metadata.issuer) if oauth_metadata else None
|
| 252 |
+
|
| 253 |
+
if iss is not None:
|
| 254 |
+
if iss != expected:
|
| 255 |
+
raise OAuthFlowError(f"Authorization response iss mismatch: {iss} != {expected}")
|
| 256 |
+
return
|
| 257 |
+
|
| 258 |
+
if oauth_metadata is not None and oauth_metadata.authorization_response_iss_parameter_supported:
|
| 259 |
+
raise OAuthFlowError("Authorization response missing iss parameter advertised by the authorization server")
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def validate_metadata_issuer(oauth_metadata: OAuthMetadata, expected_issuer: str) -> None:
|
| 263 |
+
"""Validate that authorization server metadata `issuer` matches the discovery issuer.
|
| 264 |
+
|
| 265 |
+
Per RFC 8414 section 3.3 / SEP-2468, the `issuer` in the metadata must match the issuer
|
| 266 |
+
used to construct the well-known URL, compared as a simple string (RFC 3986 section 6.2.1).
|
| 267 |
+
|
| 268 |
+
Raises:
|
| 269 |
+
OAuthFlowError: If the metadata issuer does not match `expected_issuer`.
|
| 270 |
+
"""
|
| 271 |
+
if str(oauth_metadata.issuer) != expected_issuer:
|
| 272 |
+
raise OAuthFlowError(
|
| 273 |
+
f"Authorization server metadata issuer mismatch: {oauth_metadata.issuer} != {expected_issuer}"
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def create_oauth_metadata_request(url: str) -> Request:
|
| 278 |
+
return Request("GET", url, headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_PROTOCOL_VERSION})
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def create_client_registration_request(
|
| 282 |
+
auth_server_metadata: OAuthMetadata | None, client_metadata: OAuthClientMetadata, auth_base_url: str
|
| 283 |
+
) -> Request:
|
| 284 |
+
"""Build a client registration request."""
|
| 285 |
+
|
| 286 |
+
if auth_server_metadata and auth_server_metadata.registration_endpoint:
|
| 287 |
+
registration_url = str(auth_server_metadata.registration_endpoint)
|
| 288 |
+
else:
|
| 289 |
+
registration_url = urljoin(auth_base_url, "/register")
|
| 290 |
+
|
| 291 |
+
registration_data = client_metadata.model_dump(by_alias=True, mode="json", exclude_none=True)
|
| 292 |
+
|
| 293 |
+
return Request("POST", registration_url, json=registration_data, headers={"Content-Type": "application/json"})
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
async def handle_registration_response(response: Response) -> OAuthClientInformationFull:
|
| 297 |
+
"""Handle registration response."""
|
| 298 |
+
if response.status_code not in (200, 201):
|
| 299 |
+
await response.aread()
|
| 300 |
+
raise OAuthRegistrationError(f"Registration failed: {response.status_code} {response.text}")
|
| 301 |
+
|
| 302 |
+
try:
|
| 303 |
+
content = await response.aread()
|
| 304 |
+
body = from_json(content)
|
| 305 |
+
# `issuer` is the SDK's own binding of these credentials to the server they were
|
| 306 |
+
# registered with (SEP-2352), stamped by the auth flow - never sourced from the
|
| 307 |
+
# wire, so it is dropped before the body is parsed rather than trusted or cleared.
|
| 308 |
+
if isinstance(body, dict):
|
| 309 |
+
cast(dict[str, Any], body).pop("issuer", None)
|
| 310 |
+
return OAuthClientInformationFull.model_validate(body)
|
| 311 |
+
except ValueError as e:
|
| 312 |
+
# `from_json` reports malformed bytes/JSON as ValueError, and pydantic's
|
| 313 |
+
# ValidationError is itself a ValueError, so both parse layers surface here.
|
| 314 |
+
raise OAuthRegistrationError(f"Invalid registration response: {e}") from e
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def is_valid_client_metadata_url(url: str | None) -> bool:
|
| 318 |
+
"""Validate that a URL is suitable for use as a client_id (CIMD).
|
| 319 |
+
|
| 320 |
+
The URL must be HTTPS with a non-root pathname.
|
| 321 |
+
|
| 322 |
+
Args:
|
| 323 |
+
url: The URL to validate
|
| 324 |
+
|
| 325 |
+
Returns:
|
| 326 |
+
True if the URL is a valid HTTPS URL with a non-root pathname
|
| 327 |
+
"""
|
| 328 |
+
if not url:
|
| 329 |
+
return False
|
| 330 |
+
try:
|
| 331 |
+
parsed = urlparse(url)
|
| 332 |
+
return parsed.scheme == "https" and parsed.path not in ("", "/")
|
| 333 |
+
except Exception:
|
| 334 |
+
return False
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def credentials_match_issuer(
|
| 338 |
+
client_info: OAuthClientInformationFull, issuer: str, client_metadata_url: str | None
|
| 339 |
+
) -> bool:
|
| 340 |
+
"""Whether stored client credentials may be reused against `issuer` (SEP-2352).
|
| 341 |
+
|
| 342 |
+
A URL-based client ID (CIMD) is portable across authorization servers — the same self-hosted
|
| 343 |
+
document is resolved by whichever server is in use — so it always matches; CIMD is identified
|
| 344 |
+
by the client ID being the configured `client_metadata_url`, not by URL shape (a registration
|
| 345 |
+
server may also issue URL-shaped IDs that are bound to it). Credentials with a recorded issuer
|
| 346 |
+
match only when it equals `issuer` (simple string comparison). Credentials with no recorded
|
| 347 |
+
issuer (pre-registered, or stored before issuer binding existed) carry no binding to enforce
|
| 348 |
+
and are left as-is.
|
| 349 |
+
"""
|
| 350 |
+
if client_metadata_url is not None and client_info.client_id == client_metadata_url:
|
| 351 |
+
return True
|
| 352 |
+
if client_info.issuer is None:
|
| 353 |
+
return True
|
| 354 |
+
return client_info.issuer == issuer
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def should_use_client_metadata_url(
|
| 358 |
+
oauth_metadata: OAuthMetadata | None,
|
| 359 |
+
client_metadata_url: str | None,
|
| 360 |
+
) -> bool:
|
| 361 |
+
"""Determine if URL-based client ID (CIMD) should be used instead of DCR.
|
| 362 |
+
|
| 363 |
+
URL-based client IDs should be used when:
|
| 364 |
+
1. The server advertises client_id_metadata_document_supported=True
|
| 365 |
+
2. The client has a valid client_metadata_url configured
|
| 366 |
+
|
| 367 |
+
Args:
|
| 368 |
+
oauth_metadata: OAuth authorization server metadata
|
| 369 |
+
client_metadata_url: URL-based client ID (already validated)
|
| 370 |
+
|
| 371 |
+
Returns:
|
| 372 |
+
True if CIMD should be used, False if DCR should be used
|
| 373 |
+
"""
|
| 374 |
+
if not client_metadata_url:
|
| 375 |
+
return False
|
| 376 |
+
|
| 377 |
+
if not oauth_metadata:
|
| 378 |
+
return False
|
| 379 |
+
|
| 380 |
+
return oauth_metadata.client_id_metadata_document_supported is True
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def create_client_info_from_metadata_url(
|
| 384 |
+
client_metadata_url: str, redirect_uris: list[AnyUrl] | None = None
|
| 385 |
+
) -> OAuthClientInformationFull:
|
| 386 |
+
"""Create client information using a URL-based client ID (CIMD).
|
| 387 |
+
|
| 388 |
+
When using URL-based client IDs, the URL itself becomes the client_id
|
| 389 |
+
and no client_secret is used (token_endpoint_auth_method="none").
|
| 390 |
+
|
| 391 |
+
Args:
|
| 392 |
+
client_metadata_url: The URL to use as the client_id
|
| 393 |
+
redirect_uris: The redirect URIs from the client metadata, recorded on the client
|
| 394 |
+
information alongside the client_id
|
| 395 |
+
|
| 396 |
+
Returns:
|
| 397 |
+
OAuthClientInformationFull with the URL as client_id
|
| 398 |
+
"""
|
| 399 |
+
return OAuthClientInformationFull(
|
| 400 |
+
client_id=client_metadata_url,
|
| 401 |
+
token_endpoint_auth_method="none",
|
| 402 |
+
redirect_uris=redirect_uris,
|
| 403 |
+
)
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
async def handle_token_response_scopes(
|
| 407 |
+
response: Response,
|
| 408 |
+
) -> OAuthToken:
|
| 409 |
+
"""Parse and validate a token response.
|
| 410 |
+
|
| 411 |
+
Parses token response JSON. Callers should check response.status_code before calling.
|
| 412 |
+
|
| 413 |
+
Args:
|
| 414 |
+
response: HTTP response from token endpoint (status already checked by caller)
|
| 415 |
+
|
| 416 |
+
Returns:
|
| 417 |
+
Validated OAuthToken model
|
| 418 |
+
|
| 419 |
+
Raises:
|
| 420 |
+
OAuthTokenError: If response JSON is invalid
|
| 421 |
+
"""
|
| 422 |
+
try:
|
| 423 |
+
content = await response.aread()
|
| 424 |
+
token_response = OAuthToken.model_validate_json(content)
|
| 425 |
+
return token_response
|
| 426 |
+
except ValidationError as e: # pragma: no cover
|
| 427 |
+
raise OAuthTokenError(f"Invalid token response: {e}")
|
.mcp-venv/lib/python3.13/site-packages/mcp/client/caching.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Client-side response caching primitives (SEP-2549, protocol revision 2026-07-28)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
import time
|
| 8 |
+
from collections.abc import Callable
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from typing import Any, Final, Literal, Protocol
|
| 11 |
+
|
| 12 |
+
import anyio
|
| 13 |
+
import anyio.lowlevel
|
| 14 |
+
from mcp_types import (
|
| 15 |
+
CacheableResult,
|
| 16 |
+
PromptListChangedNotification,
|
| 17 |
+
ResourceListChangedNotification,
|
| 18 |
+
ResourceUpdatedNotification,
|
| 19 |
+
ServerNotification,
|
| 20 |
+
ToolListChangedNotification,
|
| 21 |
+
)
|
| 22 |
+
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
|
| 23 |
+
|
| 24 |
+
__all__ = [
|
| 25 |
+
"MAX_TTL_MS",
|
| 26 |
+
"CacheConfig",
|
| 27 |
+
"CacheEntry",
|
| 28 |
+
"CacheKey",
|
| 29 |
+
"CacheMode",
|
| 30 |
+
"InMemoryResponseCacheStore",
|
| 31 |
+
"ResponseCacheStore",
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
logger = logging.getLogger(__name__)
|
| 35 |
+
|
| 36 |
+
CacheMode = Literal["use", "refresh", "bypass"]
|
| 37 |
+
"""Per-call cache behavior: `"use"` serves and stores, `"refresh"` stores
|
| 38 |
+
without serving, `"bypass"` skips the cache entirely."""
|
| 39 |
+
|
| 40 |
+
MAX_TTL_MS: Final[int] = 24 * 60 * 60 * 1000
|
| 41 |
+
"""Cap on any entry's time-to-live (24 hours, in milliseconds); larger `ttlMs` values are clamped down."""
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@dataclass(frozen=True, slots=True)
|
| 45 |
+
class CacheKey:
|
| 46 |
+
"""Identity of one cached response; compare as the field tuple, never a flattened string (collision hazard)."""
|
| 47 |
+
|
| 48 |
+
method: str
|
| 49 |
+
|
| 50 |
+
params_key: str = ""
|
| 51 |
+
"""Result-affecting params discriminator: the uri for `resources/read`, `""` for the list methods."""
|
| 52 |
+
|
| 53 |
+
partition: str = ""
|
| 54 |
+
"""Coordinator-computed arm identifier; opaque to stores."""
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@dataclass(frozen=True, slots=True)
|
| 58 |
+
class CacheEntry:
|
| 59 |
+
"""One cached response with its freshness and sharing metadata."""
|
| 60 |
+
|
| 61 |
+
value: Any
|
| 62 |
+
"""The cached result; the SDK deep-copies on write and on serve, so a store may hold it as-is."""
|
| 63 |
+
|
| 64 |
+
scope: Literal["public", "private"]
|
| 65 |
+
"""Server-asserted `cacheScope`: only `"public"` entries may be shared across authorization contexts."""
|
| 66 |
+
|
| 67 |
+
expires_at: float | None
|
| 68 |
+
"""Epoch seconds after which the entry is stale; `None` is never fresh."""
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class ResponseCacheStore(Protocol):
|
| 72 |
+
"""Storage contract for the client response cache.
|
| 73 |
+
|
| 74 |
+
Each `Client` calls its store from a single event loop; per-operation
|
| 75 |
+
atomicity is the implementation's responsibility. Operations may raise -
|
| 76 |
+
the SDK degrades to a miss rather than failing the call. A serializing
|
| 77 |
+
store must round-trip `value` back to the result model object (a
|
| 78 |
+
wrong-shape entry is a miss, never an error). A lookup may issue two
|
| 79 |
+
sequential `get` calls (private arm, then public).
|
| 80 |
+
"""
|
| 81 |
+
|
| 82 |
+
async def get(self, key: CacheKey) -> CacheEntry | None: ...
|
| 83 |
+
|
| 84 |
+
async def set(self, key: CacheKey, entry: CacheEntry) -> None: ...
|
| 85 |
+
|
| 86 |
+
async def delete(self, key: CacheKey) -> None: ...
|
| 87 |
+
|
| 88 |
+
async def clear(self) -> None: ...
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@dataclass(frozen=True, slots=True)
|
| 92 |
+
class CacheConfig:
|
| 93 |
+
"""Configuration for a `Client`'s response cache.
|
| 94 |
+
|
| 95 |
+
Raises:
|
| 96 |
+
ValueError: On a custom `store` without `partition`, an empty `target_id`, or a negative `default_ttl_ms`.
|
| 97 |
+
"""
|
| 98 |
+
|
| 99 |
+
store: ResponseCacheStore | None = None
|
| 100 |
+
"""Backing store; `None` means a per-client `InMemoryResponseCacheStore`.
|
| 101 |
+
A custom store requires an explicit `partition`."""
|
| 102 |
+
|
| 103 |
+
partition: str = ""
|
| 104 |
+
"""Authorization-context identifier isolating `"private"`-scoped entries
|
| 105 |
+
within a shared store. Derive it from a verified credential - never from
|
| 106 |
+
request-supplied data or the server URL. Fixed for the `Client`'s
|
| 107 |
+
lifetime: construct a new `Client` when the principal changes."""
|
| 108 |
+
|
| 109 |
+
target_id: str | None = None
|
| 110 |
+
"""Server-identity override for custom transports and proxies where the
|
| 111 |
+
SDK cannot derive one from a URL; must be non-empty when provided."""
|
| 112 |
+
|
| 113 |
+
default_ttl_ms: int = 0
|
| 114 |
+
"""TTL in milliseconds for results carrying no `ttlMs` hint; the default `0` leaves them uncached."""
|
| 115 |
+
|
| 116 |
+
clock: Callable[[], float] = time.time
|
| 117 |
+
"""Wall-clock source returning epoch seconds; injectable for expiry tests."""
|
| 118 |
+
|
| 119 |
+
share_public: bool = False
|
| 120 |
+
"""Serve server-marked `"public"` entries across every partition in the store.
|
| 121 |
+
|
| 122 |
+
WARNING: this trusts the server's `"public"` classification for every
|
| 123 |
+
principal sharing the store - a mislabeled response leaks across tenants.
|
| 124 |
+
Constructor-level only: the per-call `cache_mode` can never widen sharing."""
|
| 125 |
+
|
| 126 |
+
def __post_init__(self) -> None:
|
| 127 |
+
if self.store is not None and not self.partition:
|
| 128 |
+
raise ValueError("a custom store requires an explicit partition")
|
| 129 |
+
if self.target_id == "":
|
| 130 |
+
raise ValueError("target_id must be a non-empty string or omitted")
|
| 131 |
+
if self.default_ttl_ms < 0:
|
| 132 |
+
raise ValueError(f"default_ttl_ms must be >= 0, got {self.default_ttl_ms}")
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
class InMemoryResponseCacheStore:
|
| 136 |
+
"""Default in-process `ResponseCacheStore`.
|
| 137 |
+
|
| 138 |
+
Method bodies are synchronous, so concurrent tasks never observe a torn
|
| 139 |
+
write. `max_entries` caps the whole store, evicting least-recently-used
|
| 140 |
+
at the cap (`0` disables it); `get` and `set` both refresh recency, so a
|
| 141 |
+
hot entry survives churn from other keys.
|
| 142 |
+
|
| 143 |
+
Raises:
|
| 144 |
+
ValueError: If `max_entries` is negative.
|
| 145 |
+
"""
|
| 146 |
+
|
| 147 |
+
def __init__(self, *, max_entries: int = 1024) -> None:
|
| 148 |
+
if max_entries < 0:
|
| 149 |
+
raise ValueError(f"max_entries must be >= 0, got {max_entries}")
|
| 150 |
+
self._max_entries = max_entries
|
| 151 |
+
self._entries: dict[CacheKey, CacheEntry] = {}
|
| 152 |
+
|
| 153 |
+
async def get(self, key: CacheKey) -> CacheEntry | None:
|
| 154 |
+
entry = self._entries.get(key)
|
| 155 |
+
if entry is not None:
|
| 156 |
+
# Pop-and-reinsert moves the key to the back: the dict's insertion order is the LRU ledger.
|
| 157 |
+
self._entries[key] = self._entries.pop(key)
|
| 158 |
+
return entry
|
| 159 |
+
|
| 160 |
+
async def set(self, key: CacheKey, entry: CacheEntry) -> None:
|
| 161 |
+
self._entries.pop(key, None)
|
| 162 |
+
self._entries[key] = entry
|
| 163 |
+
if self._max_entries and len(self._entries) > self._max_entries:
|
| 164 |
+
del self._entries[next(iter(self._entries))]
|
| 165 |
+
|
| 166 |
+
async def delete(self, key: CacheKey) -> None:
|
| 167 |
+
self._entries.pop(key, None)
|
| 168 |
+
|
| 169 |
+
async def clear(self) -> None:
|
| 170 |
+
self._entries.clear()
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
_GENERATION_MAP_CAP: Final[int] = 4096
|
| 174 |
+
"""Cap on the generation map; at the cap the oldest key's eviction-race guard is dropped (FIFO)."""
|
| 175 |
+
|
| 176 |
+
_STORE_CLEANUP_TIMEOUT: Final[float] = 5
|
| 177 |
+
"""Bound for must-complete store cleanup deletes (mirrors the dispatcher's final-write bound);
|
| 178 |
+
a wedged store delete must not hold client teardown uncancellably."""
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
class ClientResponseCache:
|
| 182 |
+
"""Coordinates the `Client` caching verbs with a `ResponseCacheStore`: keys, era gate, TTL/scope, eviction."""
|
| 183 |
+
|
| 184 |
+
def __init__(
|
| 185 |
+
self,
|
| 186 |
+
*,
|
| 187 |
+
store: ResponseCacheStore,
|
| 188 |
+
partition: str,
|
| 189 |
+
arm_id: str,
|
| 190 |
+
default_ttl_ms: int,
|
| 191 |
+
clock: Callable[[], float],
|
| 192 |
+
share_public: bool,
|
| 193 |
+
negotiated_version: Callable[[], str | None],
|
| 194 |
+
generation_map_cap: int = _GENERATION_MAP_CAP,
|
| 195 |
+
store_cleanup_timeout: float = _STORE_CLEANUP_TIMEOUT,
|
| 196 |
+
) -> None:
|
| 197 |
+
self._store = store
|
| 198 |
+
self._partition = partition
|
| 199 |
+
self._arm_id = arm_id
|
| 200 |
+
self._share_public = share_public
|
| 201 |
+
self._default_ttl_ms = default_ttl_ms
|
| 202 |
+
self._clock = clock
|
| 203 |
+
self._negotiated_version = negotiated_version
|
| 204 |
+
# A key is eviction-race-guarded iff registered here.
|
| 205 |
+
self._generations: dict[tuple[str, str], int] = {}
|
| 206 |
+
self._generation_map_cap = generation_map_cap
|
| 207 |
+
self._store_cleanup_timeout = store_cleanup_timeout
|
| 208 |
+
self._warned_store_ops: set[str] = set()
|
| 209 |
+
|
| 210 |
+
def _arm(self, scope: Literal["public", "private"]) -> str:
|
| 211 |
+
# JSON arrays so crafted arm_id/partition values cannot collide across field boundaries.
|
| 212 |
+
# The negotiated version era-scopes every arm: a session never serves an entry written
|
| 213 |
+
# under a different protocol era (its content differs - sieve-stripped fields, header
|
| 214 |
+
# filtering). Every caller runs post-connect; were that ever untrue, the supplier's
|
| 215 |
+
# None still partitions harmlessly.
|
| 216 |
+
fields: list[str | None] = [scope, self._negotiated_version(), self._arm_id]
|
| 217 |
+
if scope == "private" or not self._share_public:
|
| 218 |
+
fields.append(self._partition)
|
| 219 |
+
return json.dumps(fields)
|
| 220 |
+
|
| 221 |
+
async def read(self, method: str, params_key: str) -> CacheableResult | None:
|
| 222 |
+
"""Serve a fresh entry for the key, or `None`; the served result is a deep copy."""
|
| 223 |
+
# A hit completes without any other yielding await, so checkpoint here: a poll
|
| 224 |
+
# loop over a fresh entry must not starve spawned tasks (eviction dispatch).
|
| 225 |
+
await anyio.lowlevel.checkpoint()
|
| 226 |
+
# A wrong-shape entry raises as late as the copy, so the boundary wraps the whole read path.
|
| 227 |
+
try:
|
| 228 |
+
entry = await self._get_fresh(CacheKey(method, params_key, self._arm("private")))
|
| 229 |
+
if entry is None:
|
| 230 |
+
# After a scope flip, a stale private entry must not shadow a fresh public one.
|
| 231 |
+
entry = await self._get_fresh(CacheKey(method, params_key, self._arm("public")))
|
| 232 |
+
if entry is not None and entry.scope != "public":
|
| 233 |
+
# Never serve an entry the server scoped "private" out of the shared arm.
|
| 234 |
+
entry = None
|
| 235 |
+
copied: CacheableResult | None = None if entry is None else entry.value.model_copy(deep=True)
|
| 236 |
+
except Exception: # boundary around user store code: any read-path failure is a miss, never a failed call
|
| 237 |
+
self._warn_store_failure("get")
|
| 238 |
+
return None
|
| 239 |
+
self._warned_store_ops.discard("get")
|
| 240 |
+
return copied
|
| 241 |
+
|
| 242 |
+
async def _get_fresh(self, key: CacheKey) -> CacheEntry | None:
|
| 243 |
+
entry = await self._store.get(key)
|
| 244 |
+
if entry is None or entry.expires_at is None or entry.expires_at <= self._clock():
|
| 245 |
+
return None
|
| 246 |
+
return entry
|
| 247 |
+
|
| 248 |
+
def capture(self, method: str, params_key: str) -> int:
|
| 249 |
+
"""Register the key for eviction-race detection before the fetch; `write` takes the returned generation."""
|
| 250 |
+
gen_key = (method, params_key)
|
| 251 |
+
if gen_key not in self._generations:
|
| 252 |
+
if len(self._generations) >= self._generation_map_cap:
|
| 253 |
+
# FIFO overflow: the dropped key's race guard degrades to the accepted co-tenant class.
|
| 254 |
+
del self._generations[next(iter(self._generations))]
|
| 255 |
+
self._generations[gen_key] = 0
|
| 256 |
+
return self._generations[gen_key]
|
| 257 |
+
|
| 258 |
+
async def write(
|
| 259 |
+
self,
|
| 260 |
+
method: str,
|
| 261 |
+
params_key: str,
|
| 262 |
+
result: CacheableResult,
|
| 263 |
+
gen_at_capture: int,
|
| 264 |
+
mode: Literal["use", "refresh"],
|
| 265 |
+
) -> None:
|
| 266 |
+
"""Store a fetched result under the arm its resolved scope selects."""
|
| 267 |
+
gen_key = (method, params_key)
|
| 268 |
+
if self._generation_moved(gen_key, gen_at_capture):
|
| 269 |
+
return # the key was evicted while the fetch was in flight
|
| 270 |
+
ttl_ms, scope = self._resolve(result)
|
| 271 |
+
private_key = CacheKey(method, params_key, self._arm("private"))
|
| 272 |
+
public_key = CacheKey(method, params_key, self._arm("public"))
|
| 273 |
+
if ttl_ms <= 0:
|
| 274 |
+
if mode == "refresh":
|
| 275 |
+
# The refetch superseded the warm entry, which a cancellation must not leave serving.
|
| 276 |
+
await self._cleanup_delete(private_key, public_key)
|
| 277 |
+
return
|
| 278 |
+
own, opposite = (public_key, private_key) if scope == "public" else (private_key, public_key)
|
| 279 |
+
# Opposite arm first: a failed delete aborts before the set - never two arms answering for one key.
|
| 280 |
+
if not await self._delete(opposite):
|
| 281 |
+
# The own arm's entry is superseded too: best-effort delete, degrading to a full miss.
|
| 282 |
+
await self._cleanup_delete(own)
|
| 283 |
+
return
|
| 284 |
+
entry = CacheEntry(value=result.model_copy(deep=True), scope=scope, expires_at=self._clock() + ttl_ms / 1000)
|
| 285 |
+
try:
|
| 286 |
+
if not await self._set(own, entry):
|
| 287 |
+
# The fetch superseded any pre-existing own-arm entry, and the failed set
|
| 288 |
+
# left it in place: purge it (mirrors the opposite-arm-failure path).
|
| 289 |
+
await self._cleanup_delete(own)
|
| 290 |
+
finally:
|
| 291 |
+
# An eviction can land while the set commits - even when the await
|
| 292 |
+
# is cancelled - so re-check on every exit; the delete must complete
|
| 293 |
+
# so the pending cancellation cannot resurrect the evicted entry.
|
| 294 |
+
if self._generation_moved(gen_key, gen_at_capture):
|
| 295 |
+
await self._cleanup_delete(own)
|
| 296 |
+
|
| 297 |
+
async def evict_method(self, method: str) -> None:
|
| 298 |
+
"""Evict the method's cursor-less entry."""
|
| 299 |
+
await self.evict_key(method, "")
|
| 300 |
+
|
| 301 |
+
async def evict_key(self, method: str, params_key: str) -> None:
|
| 302 |
+
"""Evict one key from both arms.
|
| 303 |
+
|
| 304 |
+
Only the current era's arms are touched; other-era entries in a persistent store age out by TTL.
|
| 305 |
+
"""
|
| 306 |
+
gen_key = (method, params_key)
|
| 307 |
+
# Bump first so an in-flight fetch cannot write the evicted entry back.
|
| 308 |
+
# Unregistered keys skip the bump (uris must not grow the map) but not
|
| 309 |
+
# the deletes - a persistent store may hold uncaptured entries.
|
| 310 |
+
if gen_key in self._generations:
|
| 311 |
+
self._generations[gen_key] += 1
|
| 312 |
+
# Must complete: a cancellation between the deletes would leave one arm serving the evicted entry.
|
| 313 |
+
await self._cleanup_delete(
|
| 314 |
+
CacheKey(method, params_key, self._arm("private")),
|
| 315 |
+
CacheKey(method, params_key, self._arm("public")),
|
| 316 |
+
)
|
| 317 |
+
|
| 318 |
+
async def evict_for_notification(self, notification: ServerNotification) -> None:
|
| 319 |
+
"""Map a server notification to the entries it makes stale.
|
| 320 |
+
|
| 321 |
+
Eviction is eventual (spawned-task dispatch): the generation bump closes
|
| 322 |
+
the write-back race; a racing read may briefly serve the old entry.
|
| 323 |
+
"""
|
| 324 |
+
match notification:
|
| 325 |
+
case ToolListChangedNotification():
|
| 326 |
+
await self.evict_method("tools/list")
|
| 327 |
+
case PromptListChangedNotification():
|
| 328 |
+
await self.evict_method("prompts/list")
|
| 329 |
+
case ResourceListChangedNotification():
|
| 330 |
+
# Templates enumerate the same changed resource space.
|
| 331 |
+
await self.evict_method("resources/list")
|
| 332 |
+
await self.evict_method("resources/templates/list")
|
| 333 |
+
case ResourceUpdatedNotification():
|
| 334 |
+
await self.evict_key("resources/read", notification.params.uri)
|
| 335 |
+
case _:
|
| 336 |
+
pass
|
| 337 |
+
|
| 338 |
+
def _resolve(self, result: CacheableResult) -> tuple[int, Literal["public", "private"]]:
|
| 339 |
+
# A legacy peer can also put `ttlMs`/`cacheScope` keys on the wire, so
|
| 340 |
+
# wire presence is not a peer-era signal - hints count only when modern.
|
| 341 |
+
modern = self._negotiated_version() in MODERN_PROTOCOL_VERSIONS
|
| 342 |
+
if modern and "ttl_ms" in result.model_fields_set:
|
| 343 |
+
# An explicit `ttlMs: 0` stays 0, and negatives are unconstructible
|
| 344 |
+
# upstream (model ge=0, parse-seam floor) - only the cap applies.
|
| 345 |
+
ttl_ms = result.ttl_ms
|
| 346 |
+
else:
|
| 347 |
+
ttl_ms = self._default_ttl_ms
|
| 348 |
+
scope: Literal["public", "private"] = "public" if modern and result.cache_scope == "public" else "private"
|
| 349 |
+
return min(ttl_ms, MAX_TTL_MS), scope
|
| 350 |
+
|
| 351 |
+
def _generation_moved(self, gen_key: tuple[str, str], gen_at_capture: int) -> bool:
|
| 352 |
+
# A FIFO-dropped key fails open (the accepted co-tenant race) rather than discarding the fetch.
|
| 353 |
+
return self._generations.get(gen_key, gen_at_capture) != gen_at_capture
|
| 354 |
+
|
| 355 |
+
async def _set(self, key: CacheKey, entry: CacheEntry) -> bool:
|
| 356 |
+
try:
|
| 357 |
+
await self._store.set(key, entry)
|
| 358 |
+
except Exception: # boundary around user store code: nothing cached, the fetch already succeeded
|
| 359 |
+
self._warn_store_failure("set")
|
| 360 |
+
return False
|
| 361 |
+
self._warned_store_ops.discard("set")
|
| 362 |
+
return True
|
| 363 |
+
|
| 364 |
+
async def _cleanup_delete(self, *keys: CacheKey) -> None:
|
| 365 |
+
# Must-complete cleanup: shielded so a pending cancellation cannot skip the deletes,
|
| 366 |
+
# bounded so a wedged store delete cannot hold client teardown uncancellably.
|
| 367 |
+
with anyio.move_on_after(self._store_cleanup_timeout, shield=True) as scope:
|
| 368 |
+
for key in keys:
|
| 369 |
+
await self._delete(key)
|
| 370 |
+
if scope.cancelled_caught:
|
| 371 |
+
logger.warning("Response cache store delete timed out; the entry will age out by TTL")
|
| 372 |
+
|
| 373 |
+
async def _delete(self, key: CacheKey) -> bool:
|
| 374 |
+
try:
|
| 375 |
+
await self._store.delete(key)
|
| 376 |
+
except Exception: # boundary around user store code: callers decide whether a failed delete aborts
|
| 377 |
+
self._warn_store_failure("delete")
|
| 378 |
+
return False
|
| 379 |
+
self._warned_store_ops.discard("delete")
|
| 380 |
+
return True
|
| 381 |
+
|
| 382 |
+
def _warn_store_failure(self, kind: Literal["get", "set", "delete"]) -> None:
|
| 383 |
+
# One warning per failure burst, per op kind; re-armed only when that
|
| 384 |
+
# same kind succeeds, so a healthy delete cannot re-arm a broken set.
|
| 385 |
+
if kind not in self._warned_store_ops:
|
| 386 |
+
self._warned_store_ops.add(kind)
|
| 387 |
+
logger.warning("Response cache store operation failed; continuing without the cache", exc_info=True)
|
.mcp-venv/lib/python3.13/site-packages/mcp/client/client.py
ADDED
|
@@ -0,0 +1,950 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unified MCP Client that wraps ClientSession with transport management."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
import logging
|
| 7 |
+
import uuid
|
| 8 |
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
| 9 |
+
from contextlib import AbstractAsyncContextManager, AsyncExitStack
|
| 10 |
+
from dataclasses import KW_ONLY, dataclass, field
|
| 11 |
+
from typing import Any, Literal, TypeVar, cast
|
| 12 |
+
|
| 13 |
+
import anyio
|
| 14 |
+
import anyio.lowlevel
|
| 15 |
+
import mcp_types as types
|
| 16 |
+
from mcp_types import (
|
| 17 |
+
INVALID_PARAMS,
|
| 18 |
+
CacheableResult,
|
| 19 |
+
CallToolResult,
|
| 20 |
+
CompleteResult,
|
| 21 |
+
EmptyResult,
|
| 22 |
+
ErrorData,
|
| 23 |
+
GetPromptResult,
|
| 24 |
+
Implementation,
|
| 25 |
+
InputRequest,
|
| 26 |
+
InputRequiredResult,
|
| 27 |
+
InputResponse,
|
| 28 |
+
InputResponses,
|
| 29 |
+
ListPromptsResult,
|
| 30 |
+
ListResourcesResult,
|
| 31 |
+
ListResourceTemplatesResult,
|
| 32 |
+
ListToolsResult,
|
| 33 |
+
LoggingLevel,
|
| 34 |
+
PaginatedRequestParams,
|
| 35 |
+
PromptReference,
|
| 36 |
+
ReadResourceResult,
|
| 37 |
+
RequestParamsMeta,
|
| 38 |
+
ResourceTemplateReference,
|
| 39 |
+
Result,
|
| 40 |
+
ServerCapabilities,
|
| 41 |
+
)
|
| 42 |
+
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS
|
| 43 |
+
from typing_extensions import deprecated
|
| 44 |
+
|
| 45 |
+
from mcp.client._input_required import DEFAULT_INPUT_REQUIRED_MAX_ROUNDS, run_input_required_driver
|
| 46 |
+
from mcp.client._memory import InMemoryTransport
|
| 47 |
+
from mcp.client._probe import negotiate_auto
|
| 48 |
+
from mcp.client._transport import Transport
|
| 49 |
+
from mcp.client.caching import CacheConfig, CacheMode, ClientResponseCache, InMemoryResponseCacheStore
|
| 50 |
+
from mcp.client.extension import ClaimContext, ClientExtension, NotificationBinding, ResultClaim
|
| 51 |
+
from mcp.client.session import (
|
| 52 |
+
ClientRequestContext,
|
| 53 |
+
ClientSession,
|
| 54 |
+
ElicitationFnT,
|
| 55 |
+
IncomingMessage,
|
| 56 |
+
ListRootsFnT,
|
| 57 |
+
LoggingFnT,
|
| 58 |
+
MessageHandlerFnT,
|
| 59 |
+
SamplingFnT,
|
| 60 |
+
)
|
| 61 |
+
from mcp.client.streamable_http import streamable_http_client
|
| 62 |
+
from mcp.client.subscriptions import ServerEvent, Subscription
|
| 63 |
+
from mcp.client.subscriptions import listen as _listen
|
| 64 |
+
from mcp.server import Server
|
| 65 |
+
from mcp.server.mcpserver import MCPServer
|
| 66 |
+
from mcp.server.runner import modern_on_request
|
| 67 |
+
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
|
| 68 |
+
from mcp.shared.dispatcher import Dispatcher, ProgressFnT
|
| 69 |
+
from mcp.shared.exceptions import MCPDeprecationWarning, MCPError
|
| 70 |
+
from mcp.shared.extension import validate_extension_identifier
|
| 71 |
+
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
|
| 72 |
+
from mcp.shared.subscriptions import event_to_notification
|
| 73 |
+
|
| 74 |
+
logger = logging.getLogger(__name__)
|
| 75 |
+
|
| 76 |
+
ConnectMode = Literal["legacy", "auto"] | str
|
| 77 |
+
"""``mode=`` value: ``"legacy"`` (initialize handshake), ``"auto"`` (discover, fall back to
|
| 78 |
+
initialize), or a modern protocol-version string (adopt directly). The ``str`` arm is for
|
| 79 |
+
forward-compat; ``Client.__post_init__`` rejects anything outside that set at construction."""
|
| 80 |
+
|
| 81 |
+
_T = TypeVar("_T")
|
| 82 |
+
_ResultT = TypeVar("_ResultT")
|
| 83 |
+
_CacheableT = TypeVar("_CacheableT", bound=CacheableResult)
|
| 84 |
+
|
| 85 |
+
_Connector = Callable[[AsyncExitStack, ConnectMode, bool], Awaitable["Dispatcher[Any]"]]
|
| 86 |
+
"""Resolved at ``__post_init__`` from the shape of ``server`` alone: enter whatever resources
|
| 87 |
+
are needed onto the exit stack and hand back the ``Dispatcher`` ``ClientSession`` will drive.
|
| 88 |
+
``mode`` and ``raise_exceptions`` are passed at call time so they're read at the same moment
|
| 89 |
+
``__aenter__`` reads them for the handshake step."""
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _connect_transport(transport: Transport) -> _Connector:
|
| 93 |
+
"""Connector for the stream-backed paths (URL, user-supplied ``Transport``)."""
|
| 94 |
+
|
| 95 |
+
async def connect(exit_stack: AsyncExitStack, _mode: ConnectMode, _raise_exceptions: bool) -> Dispatcher[Any]:
|
| 96 |
+
read_stream, write_stream = await exit_stack.enter_async_context(transport)
|
| 97 |
+
return JSONRPCDispatcher(read_stream, write_stream)
|
| 98 |
+
|
| 99 |
+
return connect
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _connect_inproc(server: Server[Any]) -> _Connector:
|
| 103 |
+
"""Connector for an in-process ``Server``: legacy mode drives the stream loop via
|
| 104 |
+
``InMemoryTransport``; any other mode drives the modern per-request path through a
|
| 105 |
+
``DirectDispatcher`` peer pair (no streams, no JSON-RPC framing, no initialize handshake)."""
|
| 106 |
+
|
| 107 |
+
async def connect(exit_stack: AsyncExitStack, mode: ConnectMode, raise_exceptions: bool) -> Dispatcher[Any]:
|
| 108 |
+
if mode == "legacy":
|
| 109 |
+
transport = InMemoryTransport(server, raise_exceptions=raise_exceptions)
|
| 110 |
+
read_stream, write_stream = await exit_stack.enter_async_context(transport)
|
| 111 |
+
return JSONRPCDispatcher(read_stream, write_stream)
|
| 112 |
+
lifespan_state = await exit_stack.enter_async_context(server.lifespan(server))
|
| 113 |
+
client_disp, server_disp = create_direct_dispatcher_pair(raise_handler_exceptions=raise_exceptions)
|
| 114 |
+
tg = await exit_stack.enter_async_context(anyio.create_task_group())
|
| 115 |
+
exit_stack.callback(server_disp.close)
|
| 116 |
+
on_request = modern_on_request(server, lifespan_state)
|
| 117 |
+
await tg.start(server_disp.run, on_request, _no_inbound_client_notifications)
|
| 118 |
+
return client_disp
|
| 119 |
+
|
| 120 |
+
return connect
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def _connected(value: _T | None) -> _T:
|
| 124 |
+
"""Narrow a post-handshake session attribute from ``T | None`` to ``T``.
|
| 125 |
+
|
| 126 |
+
``Client.__aenter__`` only assigns ``_session`` after the handshake succeeds, so inside
|
| 127 |
+
``async with Client(...)`` these attributes are always populated; the ``.session`` gate
|
| 128 |
+
raises before this is reached otherwise. The guard exists for pyright, not runtime.
|
| 129 |
+
"""
|
| 130 |
+
if value is None: # pragma: no cover
|
| 131 |
+
raise RuntimeError("Client must be used within an async context manager")
|
| 132 |
+
return value
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _strip_userinfo(url: str) -> str:
|
| 136 |
+
"""Drop any userinfo from the URL's authority component; byte-exact otherwise.
|
| 137 |
+
|
| 138 |
+
Credentials must not enter cache-key material; any further normalization could merge distinct servers.
|
| 139 |
+
"""
|
| 140 |
+
# Pure text, no urlsplit: it strips embedded tab/CR/LF before parsing, which would misalign slices.
|
| 141 |
+
sep = url.find("//")
|
| 142 |
+
if sep == -1:
|
| 143 |
+
return url
|
| 144 |
+
start = sep + 2
|
| 145 |
+
end = len(url)
|
| 146 |
+
for delimiter in "/?#":
|
| 147 |
+
if (found := url.find(delimiter, start)) != -1:
|
| 148 |
+
end = min(end, found)
|
| 149 |
+
authority = url[start:end]
|
| 150 |
+
if "@" not in authority:
|
| 151 |
+
return url
|
| 152 |
+
return url[:start] + authority.rpartition("@")[2] + url[end:]
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _evicting_message_handler(cache: ClientResponseCache, user_handler: MessageHandlerFnT | None) -> MessageHandlerFnT:
|
| 156 |
+
"""Wrap the session message handler with cache eviction on server notifications."""
|
| 157 |
+
|
| 158 |
+
async def handler(message: IncomingMessage) -> None:
|
| 159 |
+
if isinstance(message, types.ServerNotification):
|
| 160 |
+
try:
|
| 161 |
+
await cache.evict_for_notification(message)
|
| 162 |
+
except Exception: # boundary: eviction reaches user store code; a cache fault must not block delivery
|
| 163 |
+
logger.exception("Response cache eviction failed; the notification is still delivered")
|
| 164 |
+
if user_handler is not None:
|
| 165 |
+
await user_handler(message)
|
| 166 |
+
else:
|
| 167 |
+
# Mirrors ClientSession's default handler (session._default_message_handler).
|
| 168 |
+
await anyio.lowlevel.checkpoint()
|
| 169 |
+
|
| 170 |
+
return handler
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _synthesize_discover(protocol_version: str) -> types.DiscoverResult:
|
| 174 |
+
return types.DiscoverResult(
|
| 175 |
+
supported_versions=[protocol_version],
|
| 176 |
+
capabilities=types.ServerCapabilities(),
|
| 177 |
+
result_type="complete",
|
| 178 |
+
ttl_ms=0,
|
| 179 |
+
cache_scope="public",
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
async def _no_inbound_client_notifications(_dctx: Any, _method: str, _params: Mapping[str, Any] | None) -> None:
|
| 184 |
+
"""Server-side inbound ``OnNotify`` for the modern in-process path — receives nothing.
|
| 185 |
+
|
| 186 |
+
At 2026-07-28 the spec defines no client→server notifications: ``initialized`` and
|
| 187 |
+
``roots/list_changed`` are removed, and cancellation is structural (anyio scope cancel
|
| 188 |
+
through the direct await, not a notify). Server→client notifications (progress, log
|
| 189 |
+
messages) flow the other way via the per-request ``DispatchContext`` into the client's
|
| 190 |
+
callbacks, and are not seen here.
|
| 191 |
+
"""
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
@dataclass(frozen=True)
|
| 195 |
+
class _FoldedExtensions:
|
| 196 |
+
"""`Client.extensions` instances folded into the shapes `ClientSession` consumes."""
|
| 197 |
+
|
| 198 |
+
ad: dict[str, dict[str, Any]] | None
|
| 199 |
+
claims: dict[str, tuple[ResultClaim[Any], ...]] | None
|
| 200 |
+
bindings: tuple[NotificationBinding[Any], ...] | None
|
| 201 |
+
by_model: Mapping[type[Result], ResultClaim[Any]]
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def _fold_extensions(extensions: Sequence[ClientExtension] | None) -> _FoldedExtensions:
|
| 205 |
+
"""Fold extension contributions at construction, naming both owners on duplicate tags or methods."""
|
| 206 |
+
if isinstance(extensions, Mapping):
|
| 207 |
+
raise TypeError(
|
| 208 |
+
"extensions= takes a sequence of ClientExtension instances. The mapping form was "
|
| 209 |
+
"replaced: use advertise(identifier, settings) for advertise-only entries"
|
| 210 |
+
)
|
| 211 |
+
if not extensions:
|
| 212 |
+
return _FoldedExtensions(ad=None, claims=None, bindings=None, by_model={})
|
| 213 |
+
ad: dict[str, dict[str, Any]] = {}
|
| 214 |
+
claims: dict[str, tuple[ResultClaim[Any], ...]] = {}
|
| 215 |
+
bindings: list[NotificationBinding[Any]] = []
|
| 216 |
+
by_model: dict[type[Result], ResultClaim[Any]] = {}
|
| 217 |
+
claim_owners: dict[str, str] = {}
|
| 218 |
+
binding_owners: dict[str, str] = {}
|
| 219 |
+
for extension in extensions:
|
| 220 |
+
identifier = getattr(extension, "identifier", None)
|
| 221 |
+
if identifier is None:
|
| 222 |
+
raise ValueError(
|
| 223 |
+
f"{type(extension).__name__} has no `identifier`; a ClientExtension must set the "
|
| 224 |
+
"`identifier` class attribute (or assign one in `__init__`) before it can be used"
|
| 225 |
+
)
|
| 226 |
+
validate_extension_identifier(identifier, owner=type(extension).__name__)
|
| 227 |
+
if identifier in ad:
|
| 228 |
+
raise ValueError(f"extension identifier {identifier!r} is passed more than once")
|
| 229 |
+
ad[identifier] = extension.settings()
|
| 230 |
+
extension_claims = tuple(extension.claims())
|
| 231 |
+
for claim in extension_claims:
|
| 232 |
+
tag = claim.result_type
|
| 233 |
+
if tag in claim_owners:
|
| 234 |
+
owner = claim_owners[tag]
|
| 235 |
+
both = (
|
| 236 |
+
f"extension {identifier!r} claims"
|
| 237 |
+
if owner == identifier
|
| 238 |
+
else (f"extensions {owner!r} and {identifier!r} both claim")
|
| 239 |
+
)
|
| 240 |
+
raise ValueError(f"{both} resultType {tag!r}; a wire tag can have only one resolver")
|
| 241 |
+
claim_owners[tag] = identifier
|
| 242 |
+
# Each model pins its result_type Literal to one tag, so this index cannot collide.
|
| 243 |
+
by_model[claim.model] = claim
|
| 244 |
+
if extension_claims:
|
| 245 |
+
claims[identifier] = extension_claims
|
| 246 |
+
for binding in extension.notifications():
|
| 247 |
+
if binding.method in binding_owners:
|
| 248 |
+
owner = binding_owners[binding.method]
|
| 249 |
+
both = (
|
| 250 |
+
f"extension {identifier!r} binds"
|
| 251 |
+
if owner == identifier
|
| 252 |
+
else (f"extensions {owner!r} and {identifier!r} both bind")
|
| 253 |
+
)
|
| 254 |
+
raise ValueError(f"{both} notification method {binding.method!r}; a method can have only one observer")
|
| 255 |
+
binding_owners[binding.method] = identifier
|
| 256 |
+
bindings.append(binding)
|
| 257 |
+
return _FoldedExtensions(ad=ad, claims=claims or None, bindings=tuple(bindings) or None, by_model=by_model)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
@dataclass
|
| 261 |
+
class Client:
|
| 262 |
+
"""A high-level MCP client for connecting to MCP servers.
|
| 263 |
+
|
| 264 |
+
Supports in-memory transport for testing (pass a Server or MCPServer instance),
|
| 265 |
+
Streamable HTTP transport (pass a URL string), or a custom Transport instance.
|
| 266 |
+
|
| 267 |
+
Example:
|
| 268 |
+
```python
|
| 269 |
+
from mcp.client import Client
|
| 270 |
+
from mcp.server.mcpserver import MCPServer
|
| 271 |
+
|
| 272 |
+
server = MCPServer("test")
|
| 273 |
+
|
| 274 |
+
@server.tool()
|
| 275 |
+
def add(a: int, b: int) -> int:
|
| 276 |
+
return a + b
|
| 277 |
+
|
| 278 |
+
async def main():
|
| 279 |
+
async with Client(server) as client:
|
| 280 |
+
result = await client.call_tool("add", {"a": 1, "b": 2})
|
| 281 |
+
|
| 282 |
+
asyncio.run(main())
|
| 283 |
+
```
|
| 284 |
+
"""
|
| 285 |
+
|
| 286 |
+
server: Server[Any] | MCPServer | Transport | str
|
| 287 |
+
"""The MCP server to connect to.
|
| 288 |
+
|
| 289 |
+
If the server is a `Server` or `MCPServer` instance, it will be connected in-process.
|
| 290 |
+
If the server is a URL string, it will be used as the URL for a `streamable_http_client` transport.
|
| 291 |
+
If the server is a `Transport` instance, it will be used directly.
|
| 292 |
+
"""
|
| 293 |
+
|
| 294 |
+
_: KW_ONLY
|
| 295 |
+
|
| 296 |
+
# TODO(Marcelo): When do `raise_exceptions=True` actually raises?
|
| 297 |
+
raise_exceptions: bool = False
|
| 298 |
+
"""Whether to raise exceptions from the server."""
|
| 299 |
+
|
| 300 |
+
read_timeout_seconds: float | None = None
|
| 301 |
+
"""Timeout for read operations."""
|
| 302 |
+
|
| 303 |
+
sampling_callback: SamplingFnT | None = None
|
| 304 |
+
"""Callback for handling sampling requests."""
|
| 305 |
+
|
| 306 |
+
sampling_capabilities: types.SamplingCapability | None = None
|
| 307 |
+
"""Sampling sub-capabilities (e.g. tools) declared alongside `sampling_callback`; no effect without it."""
|
| 308 |
+
|
| 309 |
+
list_roots_callback: ListRootsFnT | None = None
|
| 310 |
+
"""Callback for handling list roots requests."""
|
| 311 |
+
|
| 312 |
+
logging_callback: LoggingFnT | None = None
|
| 313 |
+
"""Callback for handling logging notifications."""
|
| 314 |
+
|
| 315 |
+
log_level: LoggingLevel | None = None
|
| 316 |
+
"""The log level to opt in to on 2026-07-28+ connections (deprecated logging feature, SEP-2577).
|
| 317 |
+
|
| 318 |
+
Modern (2026-07-28+) servers send `notifications/message` only for requests that opt in by
|
| 319 |
+
carrying `io.modelcontextprotocol/logLevel` in `_meta`, and only at or above that level. Setting
|
| 320 |
+
this stamps that opt-in on every request; `None` (the default) means no opt-in, so no log
|
| 321 |
+
messages arrive - a `logging_callback` alone is not an opt-in. No effect on handshake-era
|
| 322 |
+
connections, where the deprecated `logging/setLevel` request governs delivery instead. A
|
| 323 |
+
per-request `_meta` entry with the same key overrides this default."""
|
| 324 |
+
|
| 325 |
+
# TODO(Marcelo): Why do we have both "callback" and "handler"?
|
| 326 |
+
message_handler: MessageHandlerFnT | None = None
|
| 327 |
+
"""Callback for handling raw messages."""
|
| 328 |
+
|
| 329 |
+
client_info: Implementation | None = None
|
| 330 |
+
"""Client implementation info to send to server."""
|
| 331 |
+
|
| 332 |
+
mode: ConnectMode = "auto"
|
| 333 |
+
"""How to negotiate the protocol version.
|
| 334 |
+
|
| 335 |
+
'auto' (the default) probes `server/discover` and falls back to the initialize handshake on legacy servers;
|
| 336 |
+
for an in-process `Server`/`MCPServer` it dispatches directly without JSON-RPC framing. 'legacy' forces the
|
| 337 |
+
initialize handshake (byte-identical pre-2026 behavior). A modern protocol-version string (e.g. '2026-07-28')
|
| 338 |
+
adopts that version directly without a probe — supply `prior_discover` to reuse a known DiscoverResult, or
|
| 339 |
+
omit it to synthesize a minimal one."""
|
| 340 |
+
|
| 341 |
+
prior_discover: types.DiscoverResult | None = None
|
| 342 |
+
"""A previously-obtained DiscoverResult to install via .adopt() when mode is a version pin.
|
| 343 |
+
Ignored when mode='legacy'."""
|
| 344 |
+
|
| 345 |
+
elicitation_callback: ElicitationFnT | None = None
|
| 346 |
+
"""Callback for handling elicitation requests."""
|
| 347 |
+
|
| 348 |
+
input_required_max_rounds: int = DEFAULT_INPUT_REQUIRED_MAX_ROUNDS
|
| 349 |
+
"""Cap on `InputRequiredResult` retry rounds before `call_tool` / `get_prompt` /
|
| 350 |
+
`read_resource` give up. Use `client.session.<method>(..., allow_input_required=True)`
|
| 351 |
+
to drive the loop manually instead."""
|
| 352 |
+
|
| 353 |
+
extensions: Sequence[ClientExtension] | None = None
|
| 354 |
+
"""Opt-in client extensions (SEP-2133).
|
| 355 |
+
|
| 356 |
+
Each instance contributes its capability ad, its result claims (resolved
|
| 357 |
+
transparently by `call_tool`), and its notification bindings. For an
|
| 358 |
+
ad-only entry use `mcp.client.advertise(identifier, settings)`."""
|
| 359 |
+
|
| 360 |
+
cache: CacheConfig | None = field(default_factory=CacheConfig)
|
| 361 |
+
"""Client-side response caching for the SEP-2549 cacheable methods (2026-07-28).
|
| 362 |
+
|
| 363 |
+
The default `CacheConfig()` honors server `ttlMs`/`cacheScope` hints with a
|
| 364 |
+
per-client in-memory store; pass a customized `CacheConfig`, or `None` to
|
| 365 |
+
disable. The cacheable verbs take a per-call `cache_mode` (see `CacheMode`);
|
| 366 |
+
calls carrying `meta` always reach the server. A `CacheConfig` with a custom
|
| 367 |
+
`store` requires `target_id` when the server is not a URL (no identity can be
|
| 368 |
+
derived)."""
|
| 369 |
+
|
| 370 |
+
_entered: bool = field(init=False, default=False)
|
| 371 |
+
_session: ClientSession | None = field(init=False, default=None)
|
| 372 |
+
_exit_stack: AsyncExitStack | None = field(init=False, default=None)
|
| 373 |
+
_connect: _Connector = field(init=False, repr=False, compare=False)
|
| 374 |
+
_response_cache: ClientResponseCache | None = field(init=False, default=None, repr=False, compare=False)
|
| 375 |
+
_folded_extensions: _FoldedExtensions = field(init=False, repr=False, compare=False)
|
| 376 |
+
|
| 377 |
+
def __post_init__(self) -> None:
|
| 378 |
+
if self.mode not in ("legacy", "auto") and self.mode not in MODERN_PROTOCOL_VERSIONS:
|
| 379 |
+
hint = (
|
| 380 |
+
f" ({self.mode!r} is a handshake-era version; use mode='legacy')"
|
| 381 |
+
if self.mode in HANDSHAKE_PROTOCOL_VERSIONS
|
| 382 |
+
else ""
|
| 383 |
+
)
|
| 384 |
+
raise ValueError(
|
| 385 |
+
f"mode must be 'legacy', 'auto', or one of {list(MODERN_PROTOCOL_VERSIONS)}; got {self.mode!r}{hint}"
|
| 386 |
+
)
|
| 387 |
+
|
| 388 |
+
self._folded_extensions = _fold_extensions(self.extensions)
|
| 389 |
+
|
| 390 |
+
srv = self.server
|
| 391 |
+
if isinstance(srv, MCPServer):
|
| 392 |
+
srv = srv._lowlevel_server # pyright: ignore[reportPrivateUsage]
|
| 393 |
+
if isinstance(srv, Server):
|
| 394 |
+
self._connect = _connect_inproc(srv)
|
| 395 |
+
elif isinstance(srv, str):
|
| 396 |
+
self._connect = _connect_transport(streamable_http_client(srv))
|
| 397 |
+
else:
|
| 398 |
+
self._connect = _connect_transport(srv)
|
| 399 |
+
|
| 400 |
+
if self.cache is not None:
|
| 401 |
+
config = self.cache
|
| 402 |
+
# Only the hash below leaves this scope - the raw identity may carry credentials; never log or store it.
|
| 403 |
+
target_id = config.target_id
|
| 404 |
+
if target_id is None and isinstance(self.server, str):
|
| 405 |
+
target_id = _strip_userinfo(self.server)
|
| 406 |
+
if target_id is None:
|
| 407 |
+
if config.store is not None:
|
| 408 |
+
raise ValueError(
|
| 409 |
+
"a custom cache store requires CacheConfig.target_id when the server is not a URL: "
|
| 410 |
+
"in-process servers and Transport instances get a random per-client identity, so "
|
| 411 |
+
"their entries in a shared store could never be served to another client"
|
| 412 |
+
)
|
| 413 |
+
target_id = uuid.uuid4().hex
|
| 414 |
+
self._response_cache = ClientResponseCache(
|
| 415 |
+
store=config.store if config.store is not None else InMemoryResponseCacheStore(),
|
| 416 |
+
partition=config.partition,
|
| 417 |
+
arm_id=hashlib.sha256(target_id.encode()).hexdigest(),
|
| 418 |
+
default_ttl_ms=config.default_ttl_ms,
|
| 419 |
+
clock=config.clock,
|
| 420 |
+
share_public=config.share_public,
|
| 421 |
+
# Lazy: the negotiated version is unknown until __aenter__'s handshake.
|
| 422 |
+
negotiated_version=lambda: self._session.protocol_version if self._session is not None else None,
|
| 423 |
+
)
|
| 424 |
+
|
| 425 |
+
async def _build_session(self, exit_stack: AsyncExitStack) -> ClientSession:
|
| 426 |
+
"""Enter the resolved connector and return an un-entered ClientSession."""
|
| 427 |
+
dispatcher = await self._connect(exit_stack, self.mode, self.raise_exceptions)
|
| 428 |
+
message_handler = self.message_handler
|
| 429 |
+
if self._response_cache is not None:
|
| 430 |
+
message_handler = _evicting_message_handler(self._response_cache, self.message_handler)
|
| 431 |
+
return ClientSession(
|
| 432 |
+
dispatcher=dispatcher,
|
| 433 |
+
read_timeout_seconds=self.read_timeout_seconds,
|
| 434 |
+
sampling_callback=self.sampling_callback,
|
| 435 |
+
sampling_capabilities=self.sampling_capabilities,
|
| 436 |
+
list_roots_callback=self.list_roots_callback,
|
| 437 |
+
logging_callback=self.logging_callback,
|
| 438 |
+
log_level=self.log_level,
|
| 439 |
+
message_handler=message_handler,
|
| 440 |
+
client_info=self.client_info,
|
| 441 |
+
elicitation_callback=self.elicitation_callback,
|
| 442 |
+
extensions=self._folded_extensions.ad,
|
| 443 |
+
result_claims=self._folded_extensions.claims,
|
| 444 |
+
notification_bindings=self._folded_extensions.bindings,
|
| 445 |
+
)
|
| 446 |
+
|
| 447 |
+
async def __aenter__(self) -> Client:
|
| 448 |
+
"""Enter the async context manager."""
|
| 449 |
+
if self._entered:
|
| 450 |
+
raise RuntimeError("Client is already entered; cannot reenter")
|
| 451 |
+
self._entered = True
|
| 452 |
+
|
| 453 |
+
async with AsyncExitStack() as exit_stack:
|
| 454 |
+
session = await self._build_session(exit_stack)
|
| 455 |
+
session = await exit_stack.enter_async_context(session)
|
| 456 |
+
|
| 457 |
+
if self.mode == "legacy":
|
| 458 |
+
await session.initialize()
|
| 459 |
+
elif self.mode == "auto":
|
| 460 |
+
await negotiate_auto(session)
|
| 461 |
+
else:
|
| 462 |
+
session.adopt(self.prior_discover or _synthesize_discover(self.mode))
|
| 463 |
+
|
| 464 |
+
# Only publish the session after the handshake succeeds, so `_session is not None`
|
| 465 |
+
# implies the protocol_version/server_capabilities are populated (server_info
|
| 466 |
+
# stays optional: 2026-era servers may not identify themselves). If the
|
| 467 |
+
# handshake raised above, the local exit_stack unwinds the transport for us.
|
| 468 |
+
self._session = session
|
| 469 |
+
self._exit_stack = exit_stack.pop_all()
|
| 470 |
+
return self
|
| 471 |
+
|
| 472 |
+
async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None:
|
| 473 |
+
"""Exit the async context manager."""
|
| 474 |
+
if self._exit_stack: # pragma: no branch
|
| 475 |
+
await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
|
| 476 |
+
self._session = None
|
| 477 |
+
|
| 478 |
+
@property
|
| 479 |
+
def session(self) -> ClientSession:
|
| 480 |
+
"""Get the underlying ClientSession.
|
| 481 |
+
|
| 482 |
+
This provides access to the full ClientSession API for advanced use cases.
|
| 483 |
+
|
| 484 |
+
Raises:
|
| 485 |
+
RuntimeError: If accessed before entering the context manager.
|
| 486 |
+
"""
|
| 487 |
+
if self._session is None:
|
| 488 |
+
raise RuntimeError("Client must be used within an async context manager")
|
| 489 |
+
return self._session
|
| 490 |
+
|
| 491 |
+
# TODO(maxisbey): the by-construction shape is for __aenter__ to return a connected-view
|
| 492 |
+
# type whose protocol_version/server_capabilities are non-Optional fields,
|
| 493 |
+
# eliminating these guards (and the one in .session). Same family as resolving the
|
| 494 |
+
# transport/connector at __post_init__ so the Optional internal fields disappear.
|
| 495 |
+
# (server_info stays Optional even connected: the 2026-era stamp is optional.)
|
| 496 |
+
@property
|
| 497 |
+
def protocol_version(self) -> str:
|
| 498 |
+
"""Negotiated protocol version (set by initialize/discover/adopt during ``__aenter__``)."""
|
| 499 |
+
return _connected(self.session.protocol_version)
|
| 500 |
+
|
| 501 |
+
@property
|
| 502 |
+
def server_info(self) -> Implementation | None:
|
| 503 |
+
"""Server name/version, or `None` when the server did not identify itself.
|
| 504 |
+
|
| 505 |
+
Legacy connections always carry it (`InitializeResult.serverInfo` is
|
| 506 |
+
required); on 2026-era connections the `_meta` `serverInfo` stamp is
|
| 507 |
+
optional, so an anonymous server reads as `None`.
|
| 508 |
+
"""
|
| 509 |
+
return self.session.server_info
|
| 510 |
+
|
| 511 |
+
@property
|
| 512 |
+
def server_capabilities(self) -> ServerCapabilities:
|
| 513 |
+
"""Server capabilities (set by initialize/discover/adopt during ``__aenter__``)."""
|
| 514 |
+
return _connected(self.session.server_capabilities)
|
| 515 |
+
|
| 516 |
+
@property
|
| 517 |
+
def instructions(self) -> str | None:
|
| 518 |
+
"""Server-provided instructions text, if any."""
|
| 519 |
+
return self.session.instructions
|
| 520 |
+
|
| 521 |
+
@deprecated(
|
| 522 |
+
"ping is removed as of 2026-07-28; the method only works under mode='legacy'.",
|
| 523 |
+
category=MCPDeprecationWarning,
|
| 524 |
+
)
|
| 525 |
+
async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> EmptyResult:
|
| 526 |
+
"""Send a ping request to the server."""
|
| 527 |
+
return await self.session.send_ping(meta=meta)
|
| 528 |
+
|
| 529 |
+
@deprecated(
|
| 530 |
+
"Client-to-server progress is deprecated as of 2026-07-28; progress is server-to-client only.",
|
| 531 |
+
category=MCPDeprecationWarning,
|
| 532 |
+
)
|
| 533 |
+
async def send_progress_notification(
|
| 534 |
+
self,
|
| 535 |
+
progress_token: str | int,
|
| 536 |
+
progress: float,
|
| 537 |
+
total: float | None = None,
|
| 538 |
+
message: str | None = None,
|
| 539 |
+
) -> None:
|
| 540 |
+
"""Send a progress notification to the server."""
|
| 541 |
+
await self.session.send_progress_notification( # pyright: ignore[reportDeprecated]
|
| 542 |
+
progress_token=progress_token,
|
| 543 |
+
progress=progress,
|
| 544 |
+
total=total,
|
| 545 |
+
message=message,
|
| 546 |
+
)
|
| 547 |
+
|
| 548 |
+
@deprecated("The logging capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
|
| 549 |
+
async def set_logging_level(self, level: LoggingLevel, *, meta: RequestParamsMeta | None = None) -> EmptyResult:
|
| 550 |
+
"""Set the logging level on the server."""
|
| 551 |
+
return await self.session.set_logging_level(level=level, meta=meta) # pyright: ignore[reportDeprecated]
|
| 552 |
+
|
| 553 |
+
async def _cached_fetch(
|
| 554 |
+
self,
|
| 555 |
+
method: str,
|
| 556 |
+
*,
|
| 557 |
+
cursor: str | None,
|
| 558 |
+
meta: RequestParamsMeta | None,
|
| 559 |
+
cache_mode: CacheMode,
|
| 560 |
+
send: Callable[[], Awaitable[_CacheableT]],
|
| 561 |
+
absorb: Callable[[_CacheableT], _CacheableT] | None = None,
|
| 562 |
+
) -> _CacheableT:
|
| 563 |
+
"""Serve one of the four list verbs through the response cache.
|
| 564 |
+
|
| 565 |
+
`absorb` (tools/list only) re-applies session-side derived state to a served cache hit.
|
| 566 |
+
"""
|
| 567 |
+
cache = self._response_cache
|
| 568 |
+
if cache is None or cache_mode == "bypass":
|
| 569 |
+
return await send()
|
| 570 |
+
# A closed (or never-entered) client must raise, never serve cached entries.
|
| 571 |
+
_ = self.session
|
| 572 |
+
if meta is not None and cache_mode == "use":
|
| 573 |
+
# meta (a progress token, tracing fields) expects a wire request; fetch and replace the entry.
|
| 574 |
+
cache_mode = "refresh"
|
| 575 |
+
if cursor is not None:
|
| 576 |
+
# Continuation pages skip the cache, but an expired cursor means the listing changed (spec SHOULD evict).
|
| 577 |
+
try:
|
| 578 |
+
return await send()
|
| 579 |
+
except MCPError as e:
|
| 580 |
+
if e.code == INVALID_PARAMS:
|
| 581 |
+
await cache.evict_method(method)
|
| 582 |
+
raise
|
| 583 |
+
if cache_mode == "use" and (hit := await cache.read(method, "")) is not None:
|
| 584 |
+
# The hit is a private deep copy, so absorption may mutate it freely.
|
| 585 |
+
served = cast(_CacheableT, hit)
|
| 586 |
+
return served if absorb is None else absorb(served)
|
| 587 |
+
gen = cache.capture(method, "")
|
| 588 |
+
result = await send()
|
| 589 |
+
await cache.write(method, "", result, gen, cache_mode)
|
| 590 |
+
return result
|
| 591 |
+
|
| 592 |
+
async def list_resources(
|
| 593 |
+
self,
|
| 594 |
+
*,
|
| 595 |
+
cursor: str | None = None,
|
| 596 |
+
meta: RequestParamsMeta | None = None,
|
| 597 |
+
cache_mode: CacheMode = "use",
|
| 598 |
+
) -> ListResourcesResult:
|
| 599 |
+
"""List available resources from the server."""
|
| 600 |
+
return await self._cached_fetch(
|
| 601 |
+
"resources/list",
|
| 602 |
+
cursor=cursor,
|
| 603 |
+
meta=meta,
|
| 604 |
+
cache_mode=cache_mode,
|
| 605 |
+
send=lambda: self.session.list_resources(params=PaginatedRequestParams(cursor=cursor, _meta=meta)),
|
| 606 |
+
)
|
| 607 |
+
|
| 608 |
+
async def list_resource_templates(
|
| 609 |
+
self,
|
| 610 |
+
*,
|
| 611 |
+
cursor: str | None = None,
|
| 612 |
+
meta: RequestParamsMeta | None = None,
|
| 613 |
+
cache_mode: CacheMode = "use",
|
| 614 |
+
) -> ListResourceTemplatesResult:
|
| 615 |
+
"""List available resource templates from the server."""
|
| 616 |
+
return await self._cached_fetch(
|
| 617 |
+
"resources/templates/list",
|
| 618 |
+
cursor=cursor,
|
| 619 |
+
meta=meta,
|
| 620 |
+
cache_mode=cache_mode,
|
| 621 |
+
send=lambda: self.session.list_resource_templates(params=PaginatedRequestParams(cursor=cursor, _meta=meta)),
|
| 622 |
+
)
|
| 623 |
+
|
| 624 |
+
async def read_resource(
|
| 625 |
+
self,
|
| 626 |
+
uri: str,
|
| 627 |
+
*,
|
| 628 |
+
input_responses: InputResponses | None = None,
|
| 629 |
+
request_state: str | None = None,
|
| 630 |
+
meta: RequestParamsMeta | None = None,
|
| 631 |
+
cache_mode: CacheMode = "use",
|
| 632 |
+
) -> ReadResourceResult:
|
| 633 |
+
"""Read a resource from the server.
|
| 634 |
+
|
| 635 |
+
If the server returns an `InputRequiredResult`, the embedded input
|
| 636 |
+
requests are dispatched to this client's sampling / elicitation / roots
|
| 637 |
+
callbacks and the read is retried automatically (up to
|
| 638 |
+
`input_required_max_rounds`).
|
| 639 |
+
|
| 640 |
+
Args:
|
| 641 |
+
uri: The URI of the resource to read.
|
| 642 |
+
input_responses: Responses to seed the first call with (e.g. when
|
| 643 |
+
resuming from a persisted `InputRequiredResult`).
|
| 644 |
+
request_state: Opaque state to seed the first call with.
|
| 645 |
+
meta: Additional metadata for the request.
|
| 646 |
+
cache_mode: Cache behavior for this call (see `CacheMode`); seeded
|
| 647 |
+
calls (`input_responses` or `request_state` set) ignore it.
|
| 648 |
+
|
| 649 |
+
Returns:
|
| 650 |
+
The resource content.
|
| 651 |
+
|
| 652 |
+
Raises:
|
| 653 |
+
InputRequiredRoundsExceededError: `input_required_max_rounds` exhausted.
|
| 654 |
+
MCPError: A callback returned `ErrorData` for an embedded input request.
|
| 655 |
+
pydantic.ValidationError: The server returned a result that does not
|
| 656 |
+
conform to the negotiated protocol version.
|
| 657 |
+
"""
|
| 658 |
+
|
| 659 |
+
async def retry(r: InputResponses | None, s: str | None) -> ReadResourceResult | InputRequiredResult:
|
| 660 |
+
return await self.session.read_resource(
|
| 661 |
+
uri, input_responses=r, request_state=s, meta=meta, allow_input_required=True
|
| 662 |
+
)
|
| 663 |
+
|
| 664 |
+
# Seeded calls resume a specific exchange and must never be cached (spec MUST).
|
| 665 |
+
seeded = input_responses is not None or request_state is not None
|
| 666 |
+
cache = None if seeded else self._response_cache
|
| 667 |
+
if cache is None or cache_mode == "bypass":
|
| 668 |
+
return await self._drive_input_required(await retry(input_responses, request_state), retry)
|
| 669 |
+
# A closed (or never-entered) client must raise, never serve cached entries.
|
| 670 |
+
_ = self.session
|
| 671 |
+
if meta is not None and cache_mode == "use":
|
| 672 |
+
# Calls carrying meta always reach the server (mirrors `_cached_fetch`).
|
| 673 |
+
cache_mode = "refresh"
|
| 674 |
+
if cache_mode == "use" and (hit := await cache.read("resources/read", uri)) is not None:
|
| 675 |
+
# Only terminal first-round results are stored, so a hit legitimately skips the driver.
|
| 676 |
+
return cast(ReadResourceResult, hit)
|
| 677 |
+
gen = cache.capture("resources/read", uri)
|
| 678 |
+
first = await retry(None, None)
|
| 679 |
+
if not isinstance(first, InputRequiredResult):
|
| 680 |
+
await cache.write("resources/read", uri, first, gen, cache_mode)
|
| 681 |
+
elif cache_mode == "refresh":
|
| 682 |
+
# The refresh superseded whatever was cached, but an input_required resolution
|
| 683 |
+
# cannot be stored: purge the warm entry so it cannot be served again.
|
| 684 |
+
await cache.evict_key("resources/read", uri)
|
| 685 |
+
# Driver rounds carry inputResponses, so a terminal result reached through them is never cached (spec MUST).
|
| 686 |
+
return await self._drive_input_required(first, retry)
|
| 687 |
+
|
| 688 |
+
def listen(
|
| 689 |
+
self,
|
| 690 |
+
*,
|
| 691 |
+
tools_list_changed: bool = False,
|
| 692 |
+
prompts_list_changed: bool = False,
|
| 693 |
+
resources_list_changed: bool = False,
|
| 694 |
+
resource_subscriptions: Sequence[str] = (),
|
| 695 |
+
) -> AbstractAsyncContextManager[Subscription]:
|
| 696 |
+
"""Open a `subscriptions/listen` stream of typed change events (2026-07-28 only).
|
| 697 |
+
|
| 698 |
+
Keyword args mirror the wire `SubscriptionFilter`; entering waits for the ack (honored subset: `sub.honored`):
|
| 699 |
+
|
| 700 |
+
async with client.listen(tools_list_changed=True) as sub:
|
| 701 |
+
async for event in sub:
|
| 702 |
+
tools = await client.list_tools() # refetch on change
|
| 703 |
+
|
| 704 |
+
A graceful close ends the loop; an abrupt drop raises `SubscriptionLost`. No replay: re-listen and refetch.
|
| 705 |
+
|
| 706 |
+
Raises:
|
| 707 |
+
ListenNotSupportedError: The negotiated protocol version predates 2026-07-28.
|
| 708 |
+
MCPError: The server rejected the request or the connection failed first.
|
| 709 |
+
SubscriptionLost: The stream ended before it was acknowledged.
|
| 710 |
+
TimeoutError: The read timeout elapsed before the acknowledgment.
|
| 711 |
+
"""
|
| 712 |
+
return _listen(
|
| 713 |
+
self.session,
|
| 714 |
+
tools_list_changed=tools_list_changed,
|
| 715 |
+
prompts_list_changed=prompts_list_changed,
|
| 716 |
+
resources_list_changed=resources_list_changed,
|
| 717 |
+
resource_subscriptions=resource_subscriptions,
|
| 718 |
+
on_event=self._evict_for_listen_event if self._response_cache is not None else None,
|
| 719 |
+
)
|
| 720 |
+
|
| 721 |
+
async def _evict_for_listen_event(self, event: ServerEvent) -> None:
|
| 722 |
+
"""Finish response-cache eviction before a listen consumer can refetch.
|
| 723 |
+
|
| 724 |
+
Without it the iterator wakes first and refetches a still-warm entry, with no
|
| 725 |
+
corrective wake (events are deduplicated level triggers). The tee path repeats
|
| 726 |
+
the eviction; deliberate: idempotent, and it covers non-iterating consumers.
|
| 727 |
+
"""
|
| 728 |
+
cache = self._response_cache
|
| 729 |
+
assert cache is not None # installed as the event barrier only when a cache exists
|
| 730 |
+
try:
|
| 731 |
+
await cache.evict_for_notification(event_to_notification(event, {}))
|
| 732 |
+
except Exception: # boundary: eviction reaches user store code; a cache fault must not block delivery
|
| 733 |
+
logger.exception("Response cache eviction failed; the event is still delivered")
|
| 734 |
+
|
| 735 |
+
@deprecated(
|
| 736 |
+
"resources/subscribe is removed as of 2026-07-28; use Client.listen() instead.",
|
| 737 |
+
category=MCPDeprecationWarning,
|
| 738 |
+
)
|
| 739 |
+
async def subscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> EmptyResult:
|
| 740 |
+
"""Subscribe to resource updates (2025-era servers only)."""
|
| 741 |
+
return await self.session.subscribe_resource(uri, meta=meta) # pyright: ignore[reportDeprecated]
|
| 742 |
+
|
| 743 |
+
@deprecated(
|
| 744 |
+
"resources/unsubscribe is removed as of 2026-07-28; use Client.listen() instead.",
|
| 745 |
+
category=MCPDeprecationWarning,
|
| 746 |
+
)
|
| 747 |
+
async def unsubscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> EmptyResult:
|
| 748 |
+
"""Unsubscribe from resource updates (2025-era servers only)."""
|
| 749 |
+
return await self.session.unsubscribe_resource(uri, meta=meta) # pyright: ignore[reportDeprecated]
|
| 750 |
+
|
| 751 |
+
async def call_tool(
|
| 752 |
+
self,
|
| 753 |
+
name: str,
|
| 754 |
+
arguments: dict[str, Any] | None = None,
|
| 755 |
+
read_timeout_seconds: float | None = None,
|
| 756 |
+
progress_callback: ProgressFnT | None = None,
|
| 757 |
+
*,
|
| 758 |
+
input_responses: InputResponses | None = None,
|
| 759 |
+
request_state: str | None = None,
|
| 760 |
+
meta: RequestParamsMeta | None = None,
|
| 761 |
+
) -> CallToolResult:
|
| 762 |
+
"""Call a tool on the server.
|
| 763 |
+
|
| 764 |
+
If the server returns an `InputRequiredResult`, the embedded input
|
| 765 |
+
requests are dispatched to this client's sampling / elicitation / roots
|
| 766 |
+
callbacks and the call is retried automatically (up to
|
| 767 |
+
`input_required_max_rounds`). To drive the loop yourself — e.g. to
|
| 768 |
+
persist `request_state` across process restarts — use
|
| 769 |
+
`client.session.call_tool(..., allow_input_required=True)`. Persisted
|
| 770 |
+
state is still subject to the server's TTL, request binding, and key
|
| 771 |
+
lifetime; a server on the default process-local key rejects it after a restart.
|
| 772 |
+
|
| 773 |
+
Result shapes claimed by this client's `extensions` are finished by the
|
| 774 |
+
owning claim's resolver, whose `CallToolResult` is returned; resolver
|
| 775 |
+
exceptions propagate as-is. To receive the claimed shape yourself, use
|
| 776 |
+
`client.session.call_tool(..., allow_claimed=True)`.
|
| 777 |
+
|
| 778 |
+
Args:
|
| 779 |
+
name: The name of the tool to call.
|
| 780 |
+
arguments: Arguments to pass to the tool.
|
| 781 |
+
read_timeout_seconds: Timeout for each underlying `tools/call` round.
|
| 782 |
+
progress_callback: Callback for progress updates.
|
| 783 |
+
input_responses: Responses to seed the first call with (e.g. when
|
| 784 |
+
resuming from a persisted `InputRequiredResult`).
|
| 785 |
+
request_state: Opaque state to seed the first call with.
|
| 786 |
+
meta: Additional metadata for the request.
|
| 787 |
+
|
| 788 |
+
Returns:
|
| 789 |
+
The tool result.
|
| 790 |
+
|
| 791 |
+
Raises:
|
| 792 |
+
InputRequiredRoundsExceededError: `input_required_max_rounds` exhausted.
|
| 793 |
+
MCPError: A callback returned `ErrorData` for an embedded input request.
|
| 794 |
+
pydantic.ValidationError: The server returned a result that does not
|
| 795 |
+
conform to the negotiated protocol version.
|
| 796 |
+
"""
|
| 797 |
+
|
| 798 |
+
async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | InputRequiredResult | Result:
|
| 799 |
+
return await self.session.call_tool(
|
| 800 |
+
name,
|
| 801 |
+
arguments,
|
| 802 |
+
read_timeout_seconds=read_timeout_seconds,
|
| 803 |
+
progress_callback=progress_callback,
|
| 804 |
+
input_responses=r,
|
| 805 |
+
request_state=s,
|
| 806 |
+
meta=meta,
|
| 807 |
+
allow_input_required=True,
|
| 808 |
+
# Input rounds resolve before a claimed result, so a claim may end any round.
|
| 809 |
+
allow_claimed=True,
|
| 810 |
+
)
|
| 811 |
+
|
| 812 |
+
result = await self._drive_input_required(await retry(input_responses, request_state), retry)
|
| 813 |
+
if isinstance(result, CallToolResult):
|
| 814 |
+
return result
|
| 815 |
+
# Only claimed shapes reach this point, so the lookup is total.
|
| 816 |
+
claim = self._folded_extensions.by_model[type(result)]
|
| 817 |
+
final = await claim.resolve(
|
| 818 |
+
result,
|
| 819 |
+
ClaimContext(session=self.session, tool_name=name, read_timeout_seconds=read_timeout_seconds),
|
| 820 |
+
)
|
| 821 |
+
if not final.is_error:
|
| 822 |
+
# Match the direct path: revalidate the output schema, but never for isError results.
|
| 823 |
+
await self.session.validate_tool_result(name, final)
|
| 824 |
+
return final
|
| 825 |
+
|
| 826 |
+
async def list_prompts(
|
| 827 |
+
self,
|
| 828 |
+
*,
|
| 829 |
+
cursor: str | None = None,
|
| 830 |
+
meta: RequestParamsMeta | None = None,
|
| 831 |
+
cache_mode: CacheMode = "use",
|
| 832 |
+
) -> ListPromptsResult:
|
| 833 |
+
"""List available prompts from the server."""
|
| 834 |
+
return await self._cached_fetch(
|
| 835 |
+
"prompts/list",
|
| 836 |
+
cursor=cursor,
|
| 837 |
+
meta=meta,
|
| 838 |
+
cache_mode=cache_mode,
|
| 839 |
+
send=lambda: self.session.list_prompts(params=PaginatedRequestParams(cursor=cursor, _meta=meta)),
|
| 840 |
+
)
|
| 841 |
+
|
| 842 |
+
async def get_prompt(
|
| 843 |
+
self,
|
| 844 |
+
name: str,
|
| 845 |
+
arguments: dict[str, str] | None = None,
|
| 846 |
+
*,
|
| 847 |
+
input_responses: InputResponses | None = None,
|
| 848 |
+
request_state: str | None = None,
|
| 849 |
+
meta: RequestParamsMeta | None = None,
|
| 850 |
+
) -> GetPromptResult:
|
| 851 |
+
"""Get a prompt from the server.
|
| 852 |
+
|
| 853 |
+
If the server returns an `InputRequiredResult`, the embedded input
|
| 854 |
+
requests are dispatched to this client's sampling / elicitation / roots
|
| 855 |
+
callbacks and the get is retried automatically (up to
|
| 856 |
+
`input_required_max_rounds`).
|
| 857 |
+
|
| 858 |
+
Args:
|
| 859 |
+
name: The name of the prompt.
|
| 860 |
+
arguments: Arguments to pass to the prompt.
|
| 861 |
+
input_responses: Responses to seed the first call with (e.g. when
|
| 862 |
+
resuming from a persisted `InputRequiredResult`).
|
| 863 |
+
request_state: Opaque state to seed the first call with.
|
| 864 |
+
meta: Additional metadata for the request.
|
| 865 |
+
|
| 866 |
+
Returns:
|
| 867 |
+
The prompt content.
|
| 868 |
+
|
| 869 |
+
Raises:
|
| 870 |
+
InputRequiredRoundsExceededError: `input_required_max_rounds` exhausted.
|
| 871 |
+
MCPError: A callback returned `ErrorData` for an embedded input request.
|
| 872 |
+
pydantic.ValidationError: The server returned a result that does not
|
| 873 |
+
conform to the negotiated protocol version.
|
| 874 |
+
"""
|
| 875 |
+
|
| 876 |
+
async def retry(r: InputResponses | None, s: str | None) -> GetPromptResult | InputRequiredResult:
|
| 877 |
+
return await self.session.get_prompt(
|
| 878 |
+
name, arguments, input_responses=r, request_state=s, meta=meta, allow_input_required=True
|
| 879 |
+
)
|
| 880 |
+
|
| 881 |
+
return await self._drive_input_required(await retry(input_responses, request_state), retry)
|
| 882 |
+
|
| 883 |
+
async def _drive_input_required(
|
| 884 |
+
self,
|
| 885 |
+
first: _ResultT | InputRequiredResult,
|
| 886 |
+
retry: Callable[[InputResponses | None, str | None], Awaitable[_ResultT | InputRequiredResult]],
|
| 887 |
+
) -> _ResultT:
|
| 888 |
+
"""Hand an `InputRequiredResult` to the SEP-2322 driver, or pass a terminal result through.
|
| 889 |
+
|
| 890 |
+
`dispatch` routes each embedded request through the same callback table
|
| 891 |
+
that serves legacy server→client RPCs, so the two paths stay
|
| 892 |
+
behaviourally identical by construction.
|
| 893 |
+
"""
|
| 894 |
+
if not isinstance(first, InputRequiredResult):
|
| 895 |
+
return first
|
| 896 |
+
session = self.session
|
| 897 |
+
|
| 898 |
+
async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData:
|
| 899 |
+
ctx = ClientRequestContext(session=session, request_id=key, meta=req.params.meta if req.params else None)
|
| 900 |
+
return await session.dispatch_input_request(ctx, req)
|
| 901 |
+
|
| 902 |
+
return await run_input_required_driver(
|
| 903 |
+
first, dispatch=dispatch, retry=retry, max_rounds=self.input_required_max_rounds
|
| 904 |
+
)
|
| 905 |
+
|
| 906 |
+
async def complete(
|
| 907 |
+
self,
|
| 908 |
+
ref: ResourceTemplateReference | PromptReference,
|
| 909 |
+
argument: dict[str, str],
|
| 910 |
+
context_arguments: dict[str, str] | None = None,
|
| 911 |
+
) -> CompleteResult:
|
| 912 |
+
"""Get completions for a prompt or resource template argument.
|
| 913 |
+
|
| 914 |
+
Args:
|
| 915 |
+
ref: Reference to the prompt or resource template
|
| 916 |
+
argument: The argument to complete
|
| 917 |
+
context_arguments: Additional context arguments
|
| 918 |
+
|
| 919 |
+
Returns:
|
| 920 |
+
Completion suggestions.
|
| 921 |
+
"""
|
| 922 |
+
return await self.session.complete(ref=ref, argument=argument, context_arguments=context_arguments)
|
| 923 |
+
|
| 924 |
+
async def list_tools(
|
| 925 |
+
self,
|
| 926 |
+
*,
|
| 927 |
+
cursor: str | None = None,
|
| 928 |
+
meta: RequestParamsMeta | None = None,
|
| 929 |
+
cache_mode: CacheMode = "use",
|
| 930 |
+
) -> ListToolsResult:
|
| 931 |
+
"""List available tools from the server."""
|
| 932 |
+
return await self._cached_fetch(
|
| 933 |
+
"tools/list",
|
| 934 |
+
cursor=cursor,
|
| 935 |
+
meta=meta,
|
| 936 |
+
cache_mode=cache_mode,
|
| 937 |
+
send=lambda: self.session.list_tools(params=PaginatedRequestParams(cursor=cursor, _meta=meta)),
|
| 938 |
+
# A cache hit skips session.list_tools, so the session re-absorbs the served
|
| 939 |
+
# listing to rebuild its derived per-tool state. Hits are cursorless, but a
|
| 940 |
+
# cached page 1 can carry next_cursor - never prune on a partial listing.
|
| 941 |
+
absorb=lambda hit: self.session._absorb_tool_listing( # pyright: ignore[reportPrivateUsage]
|
| 942 |
+
hit, complete=hit.next_cursor is None
|
| 943 |
+
),
|
| 944 |
+
)
|
| 945 |
+
|
| 946 |
+
@deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
|
| 947 |
+
async def send_roots_list_changed(self) -> None:
|
| 948 |
+
"""Send a notification that the roots list has changed."""
|
| 949 |
+
# TODO(Marcelo): Currently, there is no way for the server to handle this. We should add support.
|
| 950 |
+
await self.session.send_roots_list_changed() # pyright: ignore[reportDeprecated]
|
.mcp-venv/lib/python3.13/site-packages/mcp/client/context.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Request context for MCP client handlers."""
|
| 2 |
+
|
| 3 |
+
from mcp.client.session import ClientRequestContext
|
| 4 |
+
|
| 5 |
+
__all__ = ["ClientRequestContext"]
|
.mcp-venv/lib/python3.13/site-packages/mcp/client/extension.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Opt-in extension interface for MCP clients.
|
| 2 |
+
|
| 3 |
+
Subclass `ClientExtension`, set `identifier`, override the hooks you need, and
|
| 4 |
+
pass instances to `Client(extensions=[...])`. For an identifier-only
|
| 5 |
+
capability ad, use `advertise()`.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from collections.abc import Awaitable, Callable, Sequence
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
from typing import TYPE_CHECKING, Any, Final, Generic, Literal, TypeVar, get_args
|
| 13 |
+
|
| 14 |
+
from mcp_types import CORE_RESULT_TYPES, CallToolResult, InputRequiredResult, Result
|
| 15 |
+
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
|
| 16 |
+
from pydantic import AliasChoices, AliasPath, BaseModel
|
| 17 |
+
from pydantic.fields import FieldInfo
|
| 18 |
+
|
| 19 |
+
from mcp.shared.extension import validate_extension_identifier
|
| 20 |
+
|
| 21 |
+
if TYPE_CHECKING:
|
| 22 |
+
from mcp.client.session import ClientSession
|
| 23 |
+
|
| 24 |
+
__all__ = [
|
| 25 |
+
"ClaimContext",
|
| 26 |
+
"ClientExtension",
|
| 27 |
+
"NotificationBinding",
|
| 28 |
+
"ResultClaim",
|
| 29 |
+
"UnexpectedClaimedResult",
|
| 30 |
+
"advertise",
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
_CLAIM_METHODS: Final[frozenset[str]] = frozenset({"tools/call"})
|
| 34 |
+
"""The closed set of verbs a claim may attach to; widen together with the `method` Literal."""
|
| 35 |
+
|
| 36 |
+
_RESERVED_WIRE_ALIASES: Final[frozenset[str]] = frozenset({"requestState", "inputRequests"})
|
| 37 |
+
"""Typed optional fields of the core result surface that pre-validates every inbound result."""
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _wire_keys(name: str, field: FieldInfo) -> frozenset[str]:
|
| 41 |
+
"""Every top-level wire key this field can read from or write to."""
|
| 42 |
+
keys = {field.alias or name}
|
| 43 |
+
if field.serialization_alias:
|
| 44 |
+
keys.add(field.serialization_alias)
|
| 45 |
+
validation_alias = field.validation_alias
|
| 46 |
+
choices = validation_alias.choices if isinstance(validation_alias, AliasChoices) else [validation_alias]
|
| 47 |
+
for choice in choices:
|
| 48 |
+
if isinstance(choice, AliasPath):
|
| 49 |
+
choice = choice.path[0]
|
| 50 |
+
if isinstance(choice, str):
|
| 51 |
+
keys.add(choice)
|
| 52 |
+
return frozenset(keys)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
ClaimedT = TypeVar("ClaimedT", bound=Result)
|
| 56 |
+
NotifyParamsT = TypeVar("NotifyParamsT", bound=BaseModel)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@dataclass(frozen=True, kw_only=True)
|
| 60 |
+
class ClaimContext:
|
| 61 |
+
"""Host-injected context for one `ResultClaim.resolve` call."""
|
| 62 |
+
|
| 63 |
+
session: ClientSession
|
| 64 |
+
tool_name: str
|
| 65 |
+
read_timeout_seconds: float | None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@dataclass(frozen=True, kw_only=True)
|
| 69 |
+
class ResultClaim(Generic[ClaimedT]):
|
| 70 |
+
"""One extra result shape on one spec verb, keyed by the wire `resultType`.
|
| 71 |
+
|
| 72 |
+
Active only while the declaring extension is constructed into the client and
|
| 73 |
+
the negotiated protocol version admits it. `resolve` finishes a claimed
|
| 74 |
+
result, may send follow-ups through `ctx.session`, and must return the
|
| 75 |
+
verb's ordinary result. All field constraints are enforced at construction.
|
| 76 |
+
"""
|
| 77 |
+
|
| 78 |
+
result_type: str
|
| 79 |
+
model: type[ClaimedT]
|
| 80 |
+
resolve: Callable[[ClaimedT, ClaimContext], Awaitable[CallToolResult]]
|
| 81 |
+
method: Literal["tools/call"] = "tools/call"
|
| 82 |
+
protocol_versions: frozenset[str] | None = None
|
| 83 |
+
|
| 84 |
+
def __post_init__(self) -> None:
|
| 85 |
+
if self.method not in _CLAIM_METHODS:
|
| 86 |
+
raise ValueError(f"claims attach to {sorted(_CLAIM_METHODS)} only; got method {self.method!r}")
|
| 87 |
+
if self.result_type in CORE_RESULT_TYPES:
|
| 88 |
+
raise ValueError(f"resultType {self.result_type!r} is core protocol vocabulary")
|
| 89 |
+
if Result not in self.model.__mro__: # runtime guard; the ClaimedT bound only constrains checked callers
|
| 90 |
+
raise ValueError(f"{self.model.__name__} must subclass mcp_types.Result")
|
| 91 |
+
if issubclass(self.model, CallToolResult | InputRequiredResult):
|
| 92 |
+
raise ValueError("claim models must not subclass core result types")
|
| 93 |
+
for name, model_field in self.model.model_fields.items():
|
| 94 |
+
for clash in sorted(_wire_keys(name, model_field) & _RESERVED_WIRE_ALIASES):
|
| 95 |
+
raise ValueError(
|
| 96 |
+
f"{self.model.__name__}.{name} aliases {clash!r}, a typed field of the core "
|
| 97 |
+
"result surface; a colliding value would fail core validation before the "
|
| 98 |
+
"claim adapter runs"
|
| 99 |
+
)
|
| 100 |
+
field = self.model.model_fields.get("result_type")
|
| 101 |
+
if field is None or get_args(field.annotation) != (self.result_type,):
|
| 102 |
+
raise ValueError(f"{self.model.__name__}.result_type must be Literal[{self.result_type!r}]")
|
| 103 |
+
if self.protocol_versions is not None and not self.protocol_versions:
|
| 104 |
+
raise ValueError("empty protocol_versions could never activate; use None for all")
|
| 105 |
+
if self.protocol_versions is not None and not self.protocol_versions.issubset(MODERN_PROTOCOL_VERSIONS):
|
| 106 |
+
unrecognized = sorted(self.protocol_versions.difference(MODERN_PROTOCOL_VERSIONS))
|
| 107 |
+
raise ValueError(
|
| 108 |
+
f"protocol_versions {unrecognized} are not modern protocol revisions; claimed shapes "
|
| 109 |
+
"cannot be delivered on a legacy wire (None means every modern version)"
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
class UnexpectedClaimedResult(RuntimeError):
|
| 114 |
+
"""A claimed (extension) result arrived on a `call_tool` that did not opt in.
|
| 115 |
+
|
| 116 |
+
The parsed value is carried as `result`; the server may already hold state it
|
| 117 |
+
references. Opt in via `Client(extensions=[...])` or `allow_claimed=True`.
|
| 118 |
+
"""
|
| 119 |
+
|
| 120 |
+
def __init__(self, result: Result) -> None:
|
| 121 |
+
super().__init__(
|
| 122 |
+
f"Server returned a claimed result ({type(result).__name__}); pass the owning extension to "
|
| 123 |
+
"Client(extensions=[...]) for transparent resolution, or call with allow_claimed=True "
|
| 124 |
+
"and handle the shape. The carried result may reference server-side state needing cleanup."
|
| 125 |
+
)
|
| 126 |
+
self.result = result
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@dataclass(frozen=True, kw_only=True)
|
| 130 |
+
class NotificationBinding(Generic[NotifyParamsT]):
|
| 131 |
+
"""Deliver server notifications for `method` (the bare wire name) to `handler`.
|
| 132 |
+
|
| 133 |
+
Observation-only: validated params arrive one at a time per binding, in
|
| 134 |
+
dispatch order, through a bounded queue that drops the oldest with a warning
|
| 135 |
+
on overflow. Stream transports dispatch each notification independently, so
|
| 136 |
+
near-simultaneous notifications may be dispatched out of wire order. Methods
|
| 137 |
+
the negotiated version's core tables handle are never delivered to bindings.
|
| 138 |
+
"""
|
| 139 |
+
|
| 140 |
+
method: str
|
| 141 |
+
params_type: type[NotifyParamsT]
|
| 142 |
+
handler: Callable[[NotifyParamsT], Awaitable[None]]
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
class ClientExtension:
|
| 146 |
+
"""Base class for an opt-in client extension; override only what you need.
|
| 147 |
+
|
| 148 |
+
The surface is declarative, fixed at construction, and never receives the client.
|
| 149 |
+
"""
|
| 150 |
+
|
| 151 |
+
#: Reverse-DNS extension identifier, advertised under `ClientCapabilities.extensions`.
|
| 152 |
+
identifier: str
|
| 153 |
+
|
| 154 |
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
| 155 |
+
super().__init_subclass__(**kwargs)
|
| 156 |
+
# Per-instance identifiers (assigned in __init__) are validated at consumption instead.
|
| 157 |
+
if (identifier := cls.__dict__.get("identifier")) is not None:
|
| 158 |
+
validate_extension_identifier(identifier, owner=cls.__name__)
|
| 159 |
+
|
| 160 |
+
def settings(self) -> dict[str, Any]:
|
| 161 |
+
"""Per-extension settings advertised at `ClientCapabilities.extensions[identifier]`.
|
| 162 |
+
|
| 163 |
+
Read once at `Client` construction. A claim-bearing extension is
|
| 164 |
+
advertised only at protocol versions where at least one of its claims
|
| 165 |
+
is active.
|
| 166 |
+
"""
|
| 167 |
+
return {}
|
| 168 |
+
|
| 169 |
+
def claims(self) -> Sequence[ResultClaim[Any]]:
|
| 170 |
+
"""Extra result shapes this extension claims, with their resolvers."""
|
| 171 |
+
return ()
|
| 172 |
+
|
| 173 |
+
def notifications(self) -> Sequence[NotificationBinding[Any]]:
|
| 174 |
+
"""Server notifications this extension observes."""
|
| 175 |
+
return ()
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
class _AdvertiseOnly(ClientExtension):
|
| 179 |
+
"""Ad-only extension returned by `advertise()`."""
|
| 180 |
+
|
| 181 |
+
def __init__(self, identifier: str, settings: dict[str, Any]) -> None:
|
| 182 |
+
self.identifier = identifier
|
| 183 |
+
self._settings = settings
|
| 184 |
+
|
| 185 |
+
def settings(self) -> dict[str, Any]:
|
| 186 |
+
return self._settings
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def advertise(identifier: str, settings: dict[str, Any] | None = None) -> ClientExtension:
|
| 190 |
+
"""Advertise an extension identifier (with optional settings) and nothing else.
|
| 191 |
+
|
| 192 |
+
Advertising an extension you do not implement asserts wire support you do
|
| 193 |
+
not have; for behavioral extensions construct the real extension instead.
|
| 194 |
+
"""
|
| 195 |
+
validate_extension_identifier(identifier, owner="advertise")
|
| 196 |
+
return _AdvertiseOnly(identifier, {} if settings is None else settings)
|
.mcp-venv/lib/python3.13/site-packages/mcp/client/session.py
ADDED
|
@@ -0,0 +1,1462 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
from collections.abc import Callable, Mapping, Sequence
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from functools import reduce
|
| 8 |
+
from operator import or_
|
| 9 |
+
from types import TracebackType
|
| 10 |
+
from typing import TYPE_CHECKING, Annotated, Any, Final, Literal, Protocol, TypeAlias, cast, overload
|
| 11 |
+
|
| 12 |
+
import anyio
|
| 13 |
+
import anyio.abc
|
| 14 |
+
import anyio.lowlevel
|
| 15 |
+
import mcp_types as types
|
| 16 |
+
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
| 17 |
+
from mcp_types import (
|
| 18 |
+
CLIENT_CAPABILITIES_META_KEY,
|
| 19 |
+
CLIENT_INFO_META_KEY,
|
| 20 |
+
CONNECTION_CLOSED,
|
| 21 |
+
INTERNAL_ERROR,
|
| 22 |
+
LOG_LEVEL_META_KEY,
|
| 23 |
+
METHOD_NOT_FOUND,
|
| 24 |
+
PROTOCOL_VERSION_META_KEY,
|
| 25 |
+
SERVER_INFO_META_KEY,
|
| 26 |
+
UNSUPPORTED_PROTOCOL_VERSION,
|
| 27 |
+
RequestId,
|
| 28 |
+
RequestParamsMeta,
|
| 29 |
+
)
|
| 30 |
+
from mcp_types import methods as _methods
|
| 31 |
+
from mcp_types.version import (
|
| 32 |
+
HANDSHAKE_PROTOCOL_VERSIONS,
|
| 33 |
+
LATEST_HANDSHAKE_VERSION,
|
| 34 |
+
LATEST_MODERN_VERSION,
|
| 35 |
+
MODERN_PROTOCOL_VERSIONS,
|
| 36 |
+
)
|
| 37 |
+
from pydantic import BaseModel, Discriminator, Tag, TypeAdapter, ValidationError
|
| 38 |
+
from typing_extensions import Self, TypeVar, deprecated
|
| 39 |
+
|
| 40 |
+
from mcp.client._transport import ReadStream, WriteStream
|
| 41 |
+
from mcp.client.extension import NotificationBinding, ResultClaim, UnexpectedClaimedResult
|
| 42 |
+
from mcp.client.subscriptions import ListenRoute
|
| 43 |
+
from mcp.shared._compat import resync_tracer
|
| 44 |
+
from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, ProgressFnT, as_request_id
|
| 45 |
+
from mcp.shared.exceptions import MCPDeprecationWarning, MCPError
|
| 46 |
+
from mcp.shared.inbound import (
|
| 47 |
+
MCP_METHOD_HEADER,
|
| 48 |
+
MCP_NAME_HEADER,
|
| 49 |
+
MCP_PROTOCOL_VERSION_HEADER,
|
| 50 |
+
NAME_BEARING_METHODS,
|
| 51 |
+
encode_header_value,
|
| 52 |
+
find_invalid_x_mcp_header,
|
| 53 |
+
mcp_param_headers,
|
| 54 |
+
x_mcp_header_map,
|
| 55 |
+
)
|
| 56 |
+
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher, cancelled_request_id_from_params
|
| 57 |
+
from mcp.shared.message import ClientMessageMetadata, SessionMessage
|
| 58 |
+
from mcp.shared.subscriptions import SUBSCRIPTION_ID_META_KEY, event_from_wire
|
| 59 |
+
from mcp.shared.transport_context import TransportContext
|
| 60 |
+
|
| 61 |
+
if TYPE_CHECKING:
|
| 62 |
+
# `jsonschema` is imported lazily inside `validate_tool_result`: pulling it (and its
|
| 63 |
+
# `attrs`/`referencing` tree) in at module scope costs every client that never validates.
|
| 64 |
+
from jsonschema.protocols import Validator
|
| 65 |
+
|
| 66 |
+
DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0")
|
| 67 |
+
DISCOVER_TIMEOUT_SECONDS = 10.0
|
| 68 |
+
_NOTIFICATION_QUEUE_SIZE: Final = 256
|
| 69 |
+
|
| 70 |
+
logger = logging.getLogger("client")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _clamp_inbound_ttl(raw: dict[str, Any]) -> None:
|
| 74 |
+
"""Floor a negative inbound `ttlMs` to 0 before `ge=0` validation fails the call (2026-07-28 caching SHOULD)."""
|
| 75 |
+
ttl = raw.get("ttlMs")
|
| 76 |
+
if isinstance(ttl, int | float) and not isinstance(ttl, bool) and ttl < 0:
|
| 77 |
+
raw["ttlMs"] = 0
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _same_schema(a: dict[str, Any] | None, b: dict[str, Any] | None) -> bool:
|
| 81 |
+
"""JSON equality for two output schemas.
|
| 82 |
+
|
| 83 |
+
Python `==` is not JSON equality: it conflates `True`/`1` and `False`/`0`, which JSON
|
| 84 |
+
Schema keeps distinct (`const: true` vs `const: 1`). Canonical serialization compares as
|
| 85 |
+
JSON does; where it is stricter (`1` vs `1.0`), erring toward "changed" only costs a
|
| 86 |
+
recompile, never a stale validator.
|
| 87 |
+
"""
|
| 88 |
+
return json.dumps(a, sort_keys=True) == json.dumps(b, sort_keys=True)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _preconnect_stamp(data: dict[str, Any], opts: CallOptions) -> None:
|
| 92 |
+
# initialize/discover forbid cancellation; other pre-handshake requests (lowlevel
|
| 93 |
+
# ClientSession callers may skip the handshake entirely) keep the courtesy cancel.
|
| 94 |
+
if data["method"] in ("initialize", "server/discover"):
|
| 95 |
+
opts["cancel_on_abandon"] = False
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _parse_server_info_stamp(result: types.DiscoverResult) -> types.Implementation | None:
|
| 99 |
+
"""The typed identity from a discover result's `_meta` serverInfo stamp.
|
| 100 |
+
|
| 101 |
+
The stamp is display-only per the spec, so absent and malformed both read
|
| 102 |
+
as `None` rather than failing the connection.
|
| 103 |
+
"""
|
| 104 |
+
raw = (result.meta or {}).get(SERVER_INFO_META_KEY)
|
| 105 |
+
if raw is None:
|
| 106 |
+
return None
|
| 107 |
+
try:
|
| 108 |
+
return types.Implementation.model_validate(raw)
|
| 109 |
+
except ValidationError:
|
| 110 |
+
return None
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _make_handshake_stamp(protocol_version: str) -> Callable[[dict[str, Any], CallOptions], None]:
|
| 114 |
+
def stamp(data: dict[str, Any], opts: CallOptions) -> None:
|
| 115 |
+
opts.setdefault("headers", {})[MCP_PROTOCOL_VERSION_HEADER] = protocol_version
|
| 116 |
+
|
| 117 |
+
return stamp
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _make_modern_stamp(
|
| 121 |
+
protocol_version: str,
|
| 122 |
+
client_info: dict[str, Any],
|
| 123 |
+
capabilities: dict[str, Any],
|
| 124 |
+
resolve_param_headers: Callable[[str, Mapping[str, Any]], dict[str, str]],
|
| 125 |
+
*,
|
| 126 |
+
log_level: types.LoggingLevel | None = None,
|
| 127 |
+
) -> Callable[[dict[str, Any], CallOptions], None]:
|
| 128 |
+
def stamp(data: dict[str, Any], opts: CallOptions) -> None:
|
| 129 |
+
params = data.setdefault("params", {})
|
| 130 |
+
meta = params.setdefault("_meta", {})
|
| 131 |
+
meta[PROTOCOL_VERSION_META_KEY] = protocol_version
|
| 132 |
+
meta[CLIENT_INFO_META_KEY] = client_info
|
| 133 |
+
meta[CLIENT_CAPABILITIES_META_KEY] = capabilities
|
| 134 |
+
# The per-request log-delivery opt-in (2026 logging is opt-in per
|
| 135 |
+
# request). A default the caller can override on any single call by
|
| 136 |
+
# supplying the key in that request's `_meta`, hence setdefault.
|
| 137 |
+
if log_level is not None:
|
| 138 |
+
meta.setdefault(LOG_LEVEL_META_KEY, log_level)
|
| 139 |
+
# `cancel_on_abandon` stays at the dispatcher default (True): the
|
| 140 |
+
# courtesy `notifications/cancelled` is the abandon signal. On the
|
| 141 |
+
# stream transports it is the 2026 wire's cancellation spelling; the
|
| 142 |
+
# streamable-HTTP transport translates it into aborting the request's
|
| 143 |
+
# own POST instead of writing it (the 2026 HTTP wire has no
|
| 144 |
+
# client-to-server notifications - closing the stream is the signal).
|
| 145 |
+
# The negotiation methods still opt out, mirroring `_preconnect_stamp`:
|
| 146 |
+
# the spec forbids cancelling them.
|
| 147 |
+
if data["method"] in ("initialize", "server/discover"):
|
| 148 |
+
opts["cancel_on_abandon"] = False
|
| 149 |
+
headers = opts.setdefault("headers", {})
|
| 150 |
+
headers[MCP_PROTOCOL_VERSION_HEADER] = protocol_version
|
| 151 |
+
headers[MCP_METHOD_HEADER] = data["method"]
|
| 152 |
+
name_key = NAME_BEARING_METHODS.get(data["method"])
|
| 153 |
+
if name_key is not None and isinstance(name := params.get(name_key), str):
|
| 154 |
+
headers[MCP_NAME_HEADER] = encode_header_value(name)
|
| 155 |
+
if data["method"] == "tools/call" and isinstance(name := params.get("name"), str):
|
| 156 |
+
headers.update(resolve_param_headers(name, params.get("arguments") or {}))
|
| 157 |
+
|
| 158 |
+
return stamp
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
ReceiveResultT = TypeVar("ReceiveResultT", bound=BaseModel)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
@dataclass(kw_only=True)
|
| 165 |
+
class ClientRequestContext:
|
| 166 |
+
"""Context for a server-initiated request, passed to the sampling/elicitation/list-roots callbacks."""
|
| 167 |
+
|
| 168 |
+
session: ClientSession
|
| 169 |
+
request_id: RequestId
|
| 170 |
+
meta: RequestParamsMeta | None = None
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
class SamplingFnT(Protocol):
|
| 174 |
+
async def __call__(
|
| 175 |
+
self,
|
| 176 |
+
context: ClientRequestContext,
|
| 177 |
+
params: types.CreateMessageRequestParams,
|
| 178 |
+
) -> types.CreateMessageResult | types.CreateMessageResultWithTools | types.ErrorData: ... # pragma: no branch
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
class ElicitationFnT(Protocol):
|
| 182 |
+
async def __call__(
|
| 183 |
+
self,
|
| 184 |
+
context: ClientRequestContext,
|
| 185 |
+
params: types.ElicitRequestParams,
|
| 186 |
+
) -> types.ElicitResult | types.ErrorData: ... # pragma: no branch
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
class ListRootsFnT(Protocol):
|
| 190 |
+
async def __call__(
|
| 191 |
+
self, context: ClientRequestContext
|
| 192 |
+
) -> types.ListRootsResult | types.ErrorData: ... # pragma: no branch
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
class LoggingFnT(Protocol):
|
| 196 |
+
async def __call__(self, params: types.LoggingMessageNotificationParams) -> None: ... # pragma: no branch
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
IncomingMessage: TypeAlias = types.ServerNotification | Exception
|
| 200 |
+
"""What `message_handler` receives: the server notifications the session surfaces, plus transport-level exceptions.
|
| 201 |
+
|
| 202 |
+
`notifications/cancelled` is applied by the dispatcher and never surfaced, and a
|
| 203 |
+
`notifications/subscriptions/acknowledged` for a live `listen()` stream is consumed by that
|
| 204 |
+
stream, so neither reaches the handler.
|
| 205 |
+
"""
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
class MessageHandlerFnT(Protocol):
|
| 209 |
+
async def __call__(self, message: IncomingMessage) -> None: ... # pragma: no branch
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
async def _default_message_handler(message: IncomingMessage) -> None:
|
| 213 |
+
await anyio.lowlevel.checkpoint()
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
async def _default_sampling_callback(
|
| 217 |
+
context: ClientRequestContext,
|
| 218 |
+
params: types.CreateMessageRequestParams,
|
| 219 |
+
) -> types.CreateMessageResult | types.CreateMessageResultWithTools | types.ErrorData:
|
| 220 |
+
return types.ErrorData(
|
| 221 |
+
code=types.INVALID_REQUEST,
|
| 222 |
+
message="Sampling not supported",
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
async def _default_elicitation_callback(
|
| 227 |
+
context: ClientRequestContext,
|
| 228 |
+
params: types.ElicitRequestParams,
|
| 229 |
+
) -> types.ElicitResult | types.ErrorData:
|
| 230 |
+
return types.ErrorData(
|
| 231 |
+
code=types.INVALID_REQUEST,
|
| 232 |
+
message="Elicitation not supported",
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
async def _default_list_roots_callback(
|
| 237 |
+
context: ClientRequestContext,
|
| 238 |
+
) -> types.ListRootsResult | types.ErrorData:
|
| 239 |
+
return types.ErrorData(
|
| 240 |
+
code=types.INVALID_REQUEST,
|
| 241 |
+
message="List roots not supported",
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
async def _default_logging_callback(
|
| 246 |
+
params: types.LoggingMessageNotificationParams,
|
| 247 |
+
) -> None:
|
| 248 |
+
pass
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
ClientResponse: TypeAdapter[types.ClientResult | types.ErrorData] = TypeAdapter(types.ClientResult | types.ErrorData)
|
| 252 |
+
|
| 253 |
+
# Typed against the wide parse union so adopt-built claim adapters share this attribute type.
|
| 254 |
+
_CallToolResultAdapter: TypeAdapter[types.CallToolResult | types.InputRequiredResult | types.Result] = TypeAdapter(
|
| 255 |
+
types.CallToolResult | types.InputRequiredResult
|
| 256 |
+
)
|
| 257 |
+
_GetPromptResultAdapter: TypeAdapter[types.GetPromptResult | types.InputRequiredResult] = TypeAdapter(
|
| 258 |
+
types.GetPromptResult | types.InputRequiredResult
|
| 259 |
+
)
|
| 260 |
+
_ReadResourceResultAdapter: TypeAdapter[types.ReadResourceResult | types.InputRequiredResult] = TypeAdapter(
|
| 261 |
+
types.ReadResourceResult | types.InputRequiredResult
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def _claim_active(claim: ResultClaim[Any], version: str) -> bool:
|
| 266 |
+
"""A claim is active at modern versions only, narrowed by its optional version subset."""
|
| 267 |
+
return version in MODERN_PROTOCOL_VERSIONS and (
|
| 268 |
+
claim.protocol_versions is None or version in claim.protocol_versions
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def _active_claims_at(
|
| 273 |
+
claims_by_extension: Mapping[str, tuple[ResultClaim[Any], ...]], version: str
|
| 274 |
+
) -> dict[str, ResultClaim[Any]]:
|
| 275 |
+
"""Claims active at `version`, keyed by wire tag; empty at any legacy version."""
|
| 276 |
+
return {
|
| 277 |
+
claim.result_type: claim
|
| 278 |
+
for claims in claims_by_extension.values()
|
| 279 |
+
for claim in claims
|
| 280 |
+
if _claim_active(claim, version)
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def _build_call_tool_adapter(
|
| 285 |
+
active: Mapping[str, ResultClaim[Any]],
|
| 286 |
+
) -> TypeAdapter[types.CallToolResult | types.InputRequiredResult | types.Result]:
|
| 287 |
+
"""Build a discriminated tools/call adapter: a core arm plus one arm per active claim."""
|
| 288 |
+
if not active:
|
| 289 |
+
return _CallToolResultAdapter
|
| 290 |
+
tags = frozenset(active)
|
| 291 |
+
core_arm = "core"
|
| 292 |
+
while core_arm in tags: # the routing sentinel must never collide with a claimed tag
|
| 293 |
+
core_arm += "-"
|
| 294 |
+
|
| 295 |
+
def _route(value: Any) -> str:
|
| 296 |
+
# pydantic hands the discriminator either the raw dict or an already-built model.
|
| 297 |
+
# Unknown or non-string tags route to the core arm and fail core validation there.
|
| 298 |
+
if isinstance(value, dict):
|
| 299 |
+
tag = cast("dict[str, Any]", value).get("resultType")
|
| 300 |
+
else:
|
| 301 |
+
tag = getattr(value, "result_type", None)
|
| 302 |
+
return tag if isinstance(tag, str) and tag in tags else core_arm
|
| 303 |
+
|
| 304 |
+
arms: list[Any] = [Annotated[types.CallToolResult | types.InputRequiredResult, Tag(core_arm)]]
|
| 305 |
+
arms += [Annotated[claim.model, Tag(tag)] for tag, claim in active.items()]
|
| 306 |
+
# reduce(or_) rather than Union star-unpack, which needs py3.11+.
|
| 307 |
+
return TypeAdapter(Annotated[reduce(or_, arms), Discriminator(_route)])
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def _index_claims(
|
| 311 |
+
result_claims: Mapping[str, Sequence[ResultClaim[Any]]] | None,
|
| 312 |
+
extensions: dict[str, dict[str, Any]] | None,
|
| 313 |
+
) -> dict[str, tuple[ResultClaim[Any], ...]]:
|
| 314 |
+
"""Validate and copy the claims-by-extension mapping."""
|
| 315 |
+
indexed: dict[str, tuple[ResultClaim[Any], ...]] = {}
|
| 316 |
+
seen: set[str] = set()
|
| 317 |
+
for identifier, claims in (result_claims or {}).items():
|
| 318 |
+
if extensions is None or identifier not in extensions:
|
| 319 |
+
raise ValueError(
|
| 320 |
+
f"result_claims key {identifier!r} has no extensions entry; a claim is only "
|
| 321 |
+
"advertised through its extension's capability ad"
|
| 322 |
+
)
|
| 323 |
+
if not claims:
|
| 324 |
+
raise ValueError(
|
| 325 |
+
f"result_claims[{identifier!r}] is empty and would drop the extension from "
|
| 326 |
+
"the capability ad at every version. Omit the key instead"
|
| 327 |
+
)
|
| 328 |
+
for claim in claims:
|
| 329 |
+
if claim.result_type in seen:
|
| 330 |
+
raise ValueError(f"duplicate result claim for resultType {claim.result_type!r}")
|
| 331 |
+
seen.add(claim.result_type)
|
| 332 |
+
indexed[identifier] = tuple(claims)
|
| 333 |
+
return indexed
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
def _index_bindings(
|
| 337 |
+
notification_bindings: Sequence[NotificationBinding[Any]] | None,
|
| 338 |
+
) -> dict[str, NotificationBinding[Any]]:
|
| 339 |
+
"""Index bindings by wire method, rejecting duplicates."""
|
| 340 |
+
indexed: dict[str, NotificationBinding[Any]] = {}
|
| 341 |
+
for binding in notification_bindings or ():
|
| 342 |
+
if binding.method in indexed:
|
| 343 |
+
raise ValueError(f"duplicate notification binding for method {binding.method!r}")
|
| 344 |
+
indexed[binding.method] = binding
|
| 345 |
+
return indexed
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def _input_required_unexpected(method: str) -> RuntimeError:
|
| 349 |
+
return RuntimeError(
|
| 350 |
+
"Server returned InputRequiredResult; pass allow_input_required=True to receive it "
|
| 351 |
+
f"and retry {method}(..., input_responses=..., request_state=result.request_state)."
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
class ClientSession:
|
| 356 |
+
"""Client half of an MCP connection, running on a `Dispatcher`.
|
| 357 |
+
|
| 358 |
+
Construct it over a transport's stream pair (or pass a pre-built
|
| 359 |
+
`dispatcher=`), enter as an async context manager, then call
|
| 360 |
+
`initialize()`. The dispatcher owns the receive loop and request
|
| 361 |
+
correlation; this class owns the typed MCP layer and the constructor
|
| 362 |
+
callbacks. Transport `Exception` items reach `message_handler` on any
|
| 363 |
+
stream-backed dispatcher (`JSONRPCDispatcher`), whether built here from a
|
| 364 |
+
stream pair or supplied without a stream-exception hook of its own; an
|
| 365 |
+
in-process `DirectDispatcher` carries none.
|
| 366 |
+
|
| 367 |
+
Extension `result_claims` fold into tools/call parsing at `adopt()`;
|
| 368 |
+
`notification_bindings` observe vendor notifications via bounded FIFOs.
|
| 369 |
+
"""
|
| 370 |
+
|
| 371 |
+
def __init__(
|
| 372 |
+
self,
|
| 373 |
+
read_stream: ReadStream[SessionMessage | Exception] | None = None,
|
| 374 |
+
write_stream: WriteStream[SessionMessage] | None = None,
|
| 375 |
+
read_timeout_seconds: float | None = None,
|
| 376 |
+
sampling_callback: SamplingFnT | None = None,
|
| 377 |
+
elicitation_callback: ElicitationFnT | None = None,
|
| 378 |
+
list_roots_callback: ListRootsFnT | None = None,
|
| 379 |
+
logging_callback: LoggingFnT | None = None,
|
| 380 |
+
message_handler: MessageHandlerFnT | None = None,
|
| 381 |
+
client_info: types.Implementation | None = None,
|
| 382 |
+
*,
|
| 383 |
+
log_level: types.LoggingLevel | None = None,
|
| 384 |
+
sampling_capabilities: types.SamplingCapability | None = None,
|
| 385 |
+
extensions: dict[str, dict[str, Any]] | None = None,
|
| 386 |
+
result_claims: Mapping[str, Sequence[ResultClaim[Any]]] | None = None,
|
| 387 |
+
notification_bindings: Sequence[NotificationBinding[Any]] | None = None,
|
| 388 |
+
dispatcher: Dispatcher[Any] | None = None,
|
| 389 |
+
) -> None:
|
| 390 |
+
self._session_read_timeout_seconds = read_timeout_seconds
|
| 391 |
+
self._client_info = client_info or DEFAULT_CLIENT_INFO
|
| 392 |
+
self._sampling_callback = sampling_callback or _default_sampling_callback
|
| 393 |
+
self._sampling_capabilities = sampling_capabilities
|
| 394 |
+
self._extensions = dict(extensions) if extensions is not None else None
|
| 395 |
+
self._result_claims = _index_claims(result_claims, extensions)
|
| 396 |
+
self._notification_bindings = _index_bindings(notification_bindings)
|
| 397 |
+
self._active_claims: dict[str, ResultClaim[Any]] = {}
|
| 398 |
+
self._call_tool_adapter = _CallToolResultAdapter
|
| 399 |
+
self._binding_queues: dict[
|
| 400 |
+
str, tuple[MemoryObjectSendStream[BaseModel], MemoryObjectReceiveStream[BaseModel]]
|
| 401 |
+
] = {}
|
| 402 |
+
self._elicitation_callback = elicitation_callback or _default_elicitation_callback
|
| 403 |
+
self._list_roots_callback = list_roots_callback or _default_list_roots_callback
|
| 404 |
+
self._logging_callback = logging_callback or _default_logging_callback
|
| 405 |
+
self._log_level: types.LoggingLevel | None = log_level
|
| 406 |
+
self._message_handler = message_handler or _default_message_handler
|
| 407 |
+
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
|
| 408 |
+
# Compiled output-schema validators, derived from `_tool_output_schemas` and owned by
|
| 409 |
+
# `_absorb_tool_listing`, which evicts a tool's entry whenever its schema changes.
|
| 410 |
+
self._tool_output_validators: dict[str, Validator] = {}
|
| 411 |
+
self._x_mcp_header_maps: dict[str, dict[tuple[str, ...], str]] = {}
|
| 412 |
+
self._initialize_result: types.InitializeResult | None = None
|
| 413 |
+
self._discover_result: types.DiscoverResult | None = None
|
| 414 |
+
self._discover_server_info: types.Implementation | None = None
|
| 415 |
+
self._negotiated_version: str | None = None
|
| 416 |
+
self._stamp: Callable[[dict[str, Any], CallOptions], None] = _preconnect_stamp
|
| 417 |
+
self._task_group: anyio.abc.TaskGroup | None = None
|
| 418 |
+
# subscriptions/listen demux routes; membership decides ack consumption (raw listens are never registered)
|
| 419 |
+
self._listen_routes: dict[RequestId, ListenRoute] = {}
|
| 420 |
+
if dispatcher is not None:
|
| 421 |
+
if read_stream is not None or write_stream is not None:
|
| 422 |
+
raise ValueError("pass read_stream/write_stream or dispatcher, not both")
|
| 423 |
+
self._dispatcher: Dispatcher[Any] = dispatcher
|
| 424 |
+
if isinstance(dispatcher, JSONRPCDispatcher) and dispatcher.on_stream_exception is None:
|
| 425 |
+
# Route transport-level Exception items into message_handler — only
|
| 426 |
+
# stream-backed dispatchers carry these; DirectDispatcher has none.
|
| 427 |
+
# Don't clobber a caller-supplied hook.
|
| 428 |
+
# TODO(L78): this leaves a bound-method ref on the dispatcher after the
|
| 429 |
+
# session exits (memory pin) and a second wrap of the same dispatcher would
|
| 430 |
+
# skip install. The Transport-as-Dispatcher rework (L77) removes this seam.
|
| 431 |
+
dispatcher.on_stream_exception = self._on_stream_exception
|
| 432 |
+
else:
|
| 433 |
+
if read_stream is None or write_stream is None:
|
| 434 |
+
raise ValueError("read_stream and write_stream are required when no dispatcher is given")
|
| 435 |
+
# Built eagerly so notifications can be sent before entering the context manager.
|
| 436 |
+
self._dispatcher = JSONRPCDispatcher(
|
| 437 |
+
read_stream, write_stream, on_stream_exception=self._on_stream_exception
|
| 438 |
+
)
|
| 439 |
+
|
| 440 |
+
async def __aenter__(self) -> Self:
|
| 441 |
+
self._task_group = anyio.create_task_group()
|
| 442 |
+
await self._task_group.__aenter__()
|
| 443 |
+
try:
|
| 444 |
+
# Queues must exist before the dispatcher starts: _on_notify enqueues into this dict.
|
| 445 |
+
for binding in self._notification_bindings.values():
|
| 446 |
+
send, receive = anyio.create_memory_object_stream[BaseModel](_NOTIFICATION_QUEUE_SIZE)
|
| 447 |
+
self._binding_queues[binding.method] = (send, receive)
|
| 448 |
+
await self._task_group.start(
|
| 449 |
+
self._dispatcher.run, self._on_request, self._on_notify, self._intercept_notification
|
| 450 |
+
)
|
| 451 |
+
for binding in self._notification_bindings.values():
|
| 452 |
+
_, receive = self._binding_queues[binding.method]
|
| 453 |
+
self._task_group.start_soon(self._deliver_bound_notifications, binding, receive)
|
| 454 |
+
except BaseException:
|
| 455 |
+
# Unwind the entered task group before propagating: a cancellation
|
| 456 |
+
# landing here (e.g. `move_on_after` around connect) would abandon
|
| 457 |
+
# it and anyio would later raise "exited non-innermost cancel scope".
|
| 458 |
+
task_group = self._task_group
|
| 459 |
+
self._task_group = None
|
| 460 |
+
task_group.cancel_scope.cancel()
|
| 461 |
+
# Shield the group's own scope (a new one would break LIFO exit)
|
| 462 |
+
# so a pending outer cancellation cannot re-fire inside __aexit__.
|
| 463 |
+
task_group.cancel_scope.shield = True
|
| 464 |
+
try:
|
| 465 |
+
await task_group.__aexit__(None, None, None)
|
| 466 |
+
finally:
|
| 467 |
+
self._close_binding_queues()
|
| 468 |
+
raise
|
| 469 |
+
return self
|
| 470 |
+
|
| 471 |
+
async def __aexit__(
|
| 472 |
+
self,
|
| 473 |
+
exc_type: type[BaseException] | None,
|
| 474 |
+
exc_val: BaseException | None,
|
| 475 |
+
exc_tb: TracebackType | None,
|
| 476 |
+
) -> bool | None:
|
| 477 |
+
# Exit must not block: cancel the dispatcher, binding consumers, and in-flight callbacks.
|
| 478 |
+
assert self._task_group is not None
|
| 479 |
+
self._task_group.cancel_scope.cancel()
|
| 480 |
+
try:
|
| 481 |
+
result = await self._task_group.__aexit__(exc_type, exc_val, exc_tb)
|
| 482 |
+
finally:
|
| 483 |
+
self._close_binding_queues()
|
| 484 |
+
self._settle_listen_routes_closed()
|
| 485 |
+
await resync_tracer()
|
| 486 |
+
return result
|
| 487 |
+
|
| 488 |
+
def _close_binding_queues(self) -> None:
|
| 489 |
+
# Unclosed memory object streams warn at garbage collection; close is idempotent.
|
| 490 |
+
for send, receive in self._binding_queues.values():
|
| 491 |
+
send.close()
|
| 492 |
+
receive.close()
|
| 493 |
+
self._binding_queues.clear()
|
| 494 |
+
|
| 495 |
+
async def _deliver_bound_notifications(
|
| 496 |
+
self, binding: NotificationBinding[Any], receive: MemoryObjectReceiveStream[BaseModel]
|
| 497 |
+
) -> None:
|
| 498 |
+
"""Consume one binding's FIFO, decoupled from the dispatcher so handlers can do session I/O."""
|
| 499 |
+
while True:
|
| 500 |
+
params = await receive.receive()
|
| 501 |
+
try:
|
| 502 |
+
await binding.handler(params)
|
| 503 |
+
except Exception:
|
| 504 |
+
# A raising handler costs only that delivery, as in _on_notify.
|
| 505 |
+
logger.exception("notification binding handler for %r raised", binding.method)
|
| 506 |
+
|
| 507 |
+
async def send_request(
|
| 508 |
+
self,
|
| 509 |
+
request: types.ClientRequest | types.Request[Any, Any],
|
| 510 |
+
result_type: type[ReceiveResultT] | TypeAdapter[ReceiveResultT],
|
| 511 |
+
request_read_timeout_seconds: float | None = None,
|
| 512 |
+
metadata: ClientMessageMetadata | None = None,
|
| 513 |
+
progress_callback: ProgressFnT | None = None,
|
| 514 |
+
) -> ReceiveResultT:
|
| 515 |
+
"""Send a request and wait for its typed result.
|
| 516 |
+
|
| 517 |
+
Args:
|
| 518 |
+
metadata: Streamable HTTP resumption hints.
|
| 519 |
+
|
| 520 |
+
Raises:
|
| 521 |
+
MCPError: Error response, read timeout, or connection closed.
|
| 522 |
+
RuntimeError: Called before entering the context manager.
|
| 523 |
+
ValueError: The request declares `name_param` but its params carry no string name.
|
| 524 |
+
pydantic.ValidationError: The server returned a result that does not
|
| 525 |
+
conform to the negotiated protocol version.
|
| 526 |
+
"""
|
| 527 |
+
data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
|
| 528 |
+
method: str = data["method"]
|
| 529 |
+
opts: CallOptions = {}
|
| 530 |
+
self._stamp(data, opts)
|
| 531 |
+
# The stamp runs first, so its NAME_BEARING_METHODS rows win; a missing name fails loud.
|
| 532 |
+
headers = opts.setdefault("headers", {})
|
| 533 |
+
if (key := type(request).name_param) is not None and MCP_NAME_HEADER not in headers:
|
| 534 |
+
params_data: dict[str, Any] = data.get("params") or {}
|
| 535 |
+
name = params_data.get(key)
|
| 536 |
+
if not isinstance(name, str):
|
| 537 |
+
raise ValueError(f"{method} requires params[{key!r}] for Mcp-Name")
|
| 538 |
+
headers[MCP_NAME_HEADER] = encode_header_value(name)
|
| 539 |
+
timeout = (
|
| 540 |
+
request_read_timeout_seconds
|
| 541 |
+
if request_read_timeout_seconds is not None
|
| 542 |
+
else self._session_read_timeout_seconds
|
| 543 |
+
)
|
| 544 |
+
if timeout is not None:
|
| 545 |
+
opts["timeout"] = timeout
|
| 546 |
+
if progress_callback is not None:
|
| 547 |
+
opts["on_progress"] = progress_callback
|
| 548 |
+
if metadata is not None:
|
| 549 |
+
if metadata.resumption_token is not None:
|
| 550 |
+
opts["resumption_token"] = metadata.resumption_token
|
| 551 |
+
if metadata.on_resumption_token_update is not None:
|
| 552 |
+
opts["on_resumption_token"] = metadata.on_resumption_token_update
|
| 553 |
+
raw = await self._dispatcher.send_raw_request(method, data.get("params"), opts)
|
| 554 |
+
_clamp_inbound_ttl(raw)
|
| 555 |
+
# Literal fallback covers pre-handshake and stateless; matches runner.py.
|
| 556 |
+
version = self._negotiated_version or "2025-11-25"
|
| 557 |
+
try:
|
| 558 |
+
_methods.validate_server_result(method, version, raw)
|
| 559 |
+
except KeyError:
|
| 560 |
+
pass
|
| 561 |
+
if isinstance(result_type, TypeAdapter):
|
| 562 |
+
return result_type.validate_python(raw, by_name=False)
|
| 563 |
+
return result_type.model_validate(raw, by_name=False)
|
| 564 |
+
|
| 565 |
+
async def send_notification(self, notification: types.ClientNotification) -> None:
|
| 566 |
+
"""Send a one-way notification. Usable before entering the context manager.
|
| 567 |
+
|
| 568 |
+
Fire-and-forget: after the connection has closed, the notification is
|
| 569 |
+
dropped with a debug log instead of raising.
|
| 570 |
+
"""
|
| 571 |
+
data = notification.model_dump(by_alias=True, mode="json", exclude_none=True)
|
| 572 |
+
opts: CallOptions = {}
|
| 573 |
+
self._stamp(data, opts)
|
| 574 |
+
await self._dispatcher.notify(data["method"], data.get("params"), opts)
|
| 575 |
+
|
| 576 |
+
def _build_capabilities(self, version: str) -> types.ClientCapabilities:
|
| 577 |
+
"""Build the capability ad for a wire speaking `version`.
|
| 578 |
+
|
| 579 |
+
Claim-bearing identifiers whose claims are all inactive at `version` drop, so
|
| 580 |
+
the client never advertises result shapes it would reject; claim-less
|
| 581 |
+
identifiers always advertise.
|
| 582 |
+
"""
|
| 583 |
+
extensions = self._extensions
|
| 584 |
+
if extensions is not None and self._result_claims:
|
| 585 |
+
extensions = {
|
| 586 |
+
identifier: settings
|
| 587 |
+
for identifier, settings in extensions.items()
|
| 588 |
+
if identifier not in self._result_claims
|
| 589 |
+
or any(_claim_active(claim, version) for claim in self._result_claims[identifier])
|
| 590 |
+
} or None
|
| 591 |
+
sampling = (
|
| 592 |
+
(self._sampling_capabilities or types.SamplingCapability())
|
| 593 |
+
if self._sampling_callback is not _default_sampling_callback
|
| 594 |
+
else None
|
| 595 |
+
)
|
| 596 |
+
elicitation = (
|
| 597 |
+
types.ElicitationCapability(form=types.FormElicitationCapability(), url=types.UrlElicitationCapability())
|
| 598 |
+
if self._elicitation_callback is not _default_elicitation_callback
|
| 599 |
+
else None
|
| 600 |
+
)
|
| 601 |
+
roots = (
|
| 602 |
+
# TODO: Should this be based on whether we
|
| 603 |
+
# _will_ send notifications, or only whether
|
| 604 |
+
# they're supported?
|
| 605 |
+
types.RootsCapability(list_changed=True)
|
| 606 |
+
if self._list_roots_callback is not _default_list_roots_callback
|
| 607 |
+
else None
|
| 608 |
+
)
|
| 609 |
+
return types.ClientCapabilities(
|
| 610 |
+
sampling=sampling, elicitation=elicitation, experimental=None, extensions=extensions, roots=roots
|
| 611 |
+
)
|
| 612 |
+
|
| 613 |
+
async def initialize(self) -> types.InitializeResult:
|
| 614 |
+
if self._initialize_result is not None:
|
| 615 |
+
return self._initialize_result
|
| 616 |
+
result = await self.send_request(
|
| 617 |
+
types.InitializeRequest(
|
| 618 |
+
params=types.InitializeRequestParams(
|
| 619 |
+
protocol_version=LATEST_HANDSHAKE_VERSION,
|
| 620 |
+
# The handshake negotiates only legacy versions, where no claim is active.
|
| 621 |
+
capabilities=self._build_capabilities(LATEST_HANDSHAKE_VERSION),
|
| 622 |
+
client_info=self._client_info,
|
| 623 |
+
),
|
| 624 |
+
),
|
| 625 |
+
types.InitializeResult,
|
| 626 |
+
)
|
| 627 |
+
|
| 628 |
+
if result.protocol_version not in HANDSHAKE_PROTOCOL_VERSIONS:
|
| 629 |
+
raise RuntimeError(f"Unsupported protocol version from the server: {result.protocol_version}")
|
| 630 |
+
|
| 631 |
+
self.adopt(result)
|
| 632 |
+
|
| 633 |
+
await self.send_notification(types.InitializedNotification())
|
| 634 |
+
|
| 635 |
+
return result
|
| 636 |
+
|
| 637 |
+
def adopt(self, result: types.InitializeResult | types.DiscoverResult) -> None:
|
| 638 |
+
"""Install negotiated state from a result the caller already holds (no wire traffic).
|
| 639 |
+
|
| 640 |
+
Clears the opposite slot, so at most one of `initialize_result` /
|
| 641 |
+
`discover_result` is ever non-None.
|
| 642 |
+
|
| 643 |
+
Raises:
|
| 644 |
+
RuntimeError: `result` is a `DiscoverResult` whose `supported_versions`
|
| 645 |
+
shares nothing with this client's `MODERN_PROTOCOL_VERSIONS`.
|
| 646 |
+
"""
|
| 647 |
+
if isinstance(result, types.DiscoverResult):
|
| 648 |
+
# ordered oldest→newest via MODERN_PROTOCOL_VERSIONS
|
| 649 |
+
mutual = [v for v in MODERN_PROTOCOL_VERSIONS if v in result.supported_versions]
|
| 650 |
+
if not mutual:
|
| 651 |
+
raise RuntimeError(
|
| 652 |
+
f"No mutually supported modern protocol version "
|
| 653 |
+
f"(server: {result.supported_versions}, client: {list(MODERN_PROTOCOL_VERSIONS)})"
|
| 654 |
+
)
|
| 655 |
+
version = mutual[-1]
|
| 656 |
+
client_info = self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True)
|
| 657 |
+
capabilities = self._build_capabilities(version).model_dump(by_alias=True, mode="json", exclude_none=True)
|
| 658 |
+
self._stamp = _make_modern_stamp(
|
| 659 |
+
version, client_info, capabilities, self._resolve_param_headers, log_level=self._log_level
|
| 660 |
+
)
|
| 661 |
+
self._discover_result = result
|
| 662 |
+
self._discover_server_info = _parse_server_info_stamp(result)
|
| 663 |
+
self._initialize_result = None
|
| 664 |
+
else:
|
| 665 |
+
version = result.protocol_version
|
| 666 |
+
self._stamp = _make_handshake_stamp(version)
|
| 667 |
+
self._initialize_result = result
|
| 668 |
+
self._discover_result = None
|
| 669 |
+
self._discover_server_info = None
|
| 670 |
+
self._negotiated_version = version
|
| 671 |
+
# Both arms reach here, so re-adoption resets cleanly; legacy versions activate no claims.
|
| 672 |
+
# Core-vocabulary tags are unconstructible (ResultClaim.__post_init__), so no exclusion needed.
|
| 673 |
+
self._active_claims = _active_claims_at(self._result_claims, version)
|
| 674 |
+
self._call_tool_adapter = _build_call_tool_adapter(self._active_claims)
|
| 675 |
+
for method in self._notification_bindings:
|
| 676 |
+
# Bindings are consulted only for methods core does not know, so this one can never fire.
|
| 677 |
+
if (method, version) in _methods.SERVER_NOTIFICATIONS:
|
| 678 |
+
logger.warning(
|
| 679 |
+
"notification binding for %r will never fire at %s: the core protocol defines this method",
|
| 680 |
+
method,
|
| 681 |
+
version,
|
| 682 |
+
)
|
| 683 |
+
|
| 684 |
+
async def send_discover(self, version: str) -> dict[str, Any]:
|
| 685 |
+
"""Send a single ``server/discover`` at ``version`` and return the raw result dict.
|
| 686 |
+
|
| 687 |
+
No retry, no ``adopt()``. The ``_meta`` envelope and the
|
| 688 |
+
``Mcp-Protocol-Version`` header are stamped at ``version`` so the
|
| 689 |
+
server-side era router sees a coherent probe. Used by ``discover()`` and
|
| 690 |
+
the connect-time auto-negotiation policy.
|
| 691 |
+
|
| 692 |
+
Raises:
|
| 693 |
+
MCPError: The server returned a JSON-RPC error, or the transport
|
| 694 |
+
bounced the request at its own layer (a bare HTTP 4xx is
|
| 695 |
+
synthesized into a JSON-RPC error by the transport).
|
| 696 |
+
"""
|
| 697 |
+
client_info = self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True)
|
| 698 |
+
capabilities = self._build_capabilities(version).model_dump(by_alias=True, mode="json", exclude_none=True)
|
| 699 |
+
request = types.DiscoverRequest(
|
| 700 |
+
params=types.RequestParams(
|
| 701 |
+
_meta={
|
| 702 |
+
PROTOCOL_VERSION_META_KEY: version,
|
| 703 |
+
CLIENT_INFO_META_KEY: client_info,
|
| 704 |
+
CLIENT_CAPABILITIES_META_KEY: capabilities,
|
| 705 |
+
}
|
| 706 |
+
)
|
| 707 |
+
)
|
| 708 |
+
data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
|
| 709 |
+
opts: CallOptions = {
|
| 710 |
+
"timeout": DISCOVER_TIMEOUT_SECONDS,
|
| 711 |
+
"cancel_on_abandon": False,
|
| 712 |
+
"headers": {MCP_PROTOCOL_VERSION_HEADER: version, MCP_METHOD_HEADER: data["method"]},
|
| 713 |
+
}
|
| 714 |
+
raw = await self._dispatcher.send_raw_request(data["method"], data.get("params"), opts)
|
| 715 |
+
# Un-floored, a negative ttl fails the mode='auto' probe's validation and silently downgrades the handshake.
|
| 716 |
+
_clamp_inbound_ttl(raw)
|
| 717 |
+
return raw
|
| 718 |
+
|
| 719 |
+
async def discover(self) -> types.DiscoverResult:
|
| 720 |
+
"""Probe `server/discover` and adopt the result.
|
| 721 |
+
|
| 722 |
+
Sends a single `server/discover` proposing the newest modern protocol
|
| 723 |
+
version. On `UNSUPPORTED_PROTOCOL_VERSION` (-32022) the server's
|
| 724 |
+
`supported` list is intersected with `MODERN_PROTOCOL_VERSIONS` and the
|
| 725 |
+
probe is retried once at the highest mutual version. Any other error —
|
| 726 |
+
including `METHOD_NOT_FOUND` (-32601) and `REQUEST_TIMEOUT` (-32001) —
|
| 727 |
+
propagates; the legacy `initialize()` fallback is the caller's policy.
|
| 728 |
+
|
| 729 |
+
Raises:
|
| 730 |
+
MCPError: The server rejected `server/discover`, the probe timed
|
| 731 |
+
out, or the -32022 retry found no mutual version / failed again.
|
| 732 |
+
RuntimeError: `adopt()` found no mutual version in the returned
|
| 733 |
+
`supported_versions`.
|
| 734 |
+
"""
|
| 735 |
+
if self._discover_result is not None:
|
| 736 |
+
return self._discover_result
|
| 737 |
+
|
| 738 |
+
try:
|
| 739 |
+
raw = await self.send_discover(LATEST_MODERN_VERSION)
|
| 740 |
+
except MCPError as e:
|
| 741 |
+
if e.code != UNSUPPORTED_PROTOCOL_VERSION:
|
| 742 |
+
raise
|
| 743 |
+
try:
|
| 744 |
+
data = types.UnsupportedProtocolVersionErrorData.model_validate(e.error.data)
|
| 745 |
+
except ValidationError:
|
| 746 |
+
raise e from None
|
| 747 |
+
# ordered oldest→newest via MODERN_PROTOCOL_VERSIONS
|
| 748 |
+
mutual = [v for v in MODERN_PROTOCOL_VERSIONS if v in data.supported]
|
| 749 |
+
if not mutual:
|
| 750 |
+
raise
|
| 751 |
+
raw = await self.send_discover(mutual[-1])
|
| 752 |
+
|
| 753 |
+
result = types.DiscoverResult.model_validate(raw)
|
| 754 |
+
self.adopt(result)
|
| 755 |
+
return result
|
| 756 |
+
|
| 757 |
+
@property
|
| 758 |
+
def initialize_result(self) -> types.InitializeResult | None:
|
| 759 |
+
"""The server's InitializeResult. None unless `initialize()` ran (or was adopted)."""
|
| 760 |
+
return self._initialize_result
|
| 761 |
+
|
| 762 |
+
@property
|
| 763 |
+
def discover_result(self) -> types.DiscoverResult | None:
|
| 764 |
+
"""The server's DiscoverResult. None unless `discover()` ran (or was adopted).
|
| 765 |
+
|
| 766 |
+
Retained intact (supported_versions, ttl_ms, cache_scope) so callers
|
| 767 |
+
can round-trip it as ``prior_discover=``.
|
| 768 |
+
"""
|
| 769 |
+
return self._discover_result
|
| 770 |
+
|
| 771 |
+
@property
|
| 772 |
+
def protocol_version(self) -> str | None:
|
| 773 |
+
"""Negotiated protocol version. None until `initialize()`, `discover()`, or `adopt()`."""
|
| 774 |
+
return self._negotiated_version
|
| 775 |
+
|
| 776 |
+
@property
|
| 777 |
+
def server_info(self) -> types.Implementation | None:
|
| 778 |
+
"""Server name/version. None until `initialize()`, `discover()`, or `adopt()`.
|
| 779 |
+
|
| 780 |
+
On 2026-era connections this is the discover result's optional `_meta`
|
| 781 |
+
`serverInfo` stamp, parsed once at adopt time; `None` when the server
|
| 782 |
+
did not identify itself. The stamp is display-only per the spec, so a
|
| 783 |
+
malformed value reads as absent rather than failing the connection.
|
| 784 |
+
"""
|
| 785 |
+
if self._discover_result is not None:
|
| 786 |
+
return self._discover_server_info
|
| 787 |
+
if self._initialize_result is not None:
|
| 788 |
+
return self._initialize_result.server_info
|
| 789 |
+
return None
|
| 790 |
+
|
| 791 |
+
@property
|
| 792 |
+
def server_capabilities(self) -> types.ServerCapabilities | None:
|
| 793 |
+
"""Server capabilities. None until `initialize()`, `discover()`, or `adopt()`."""
|
| 794 |
+
if self._discover_result is not None:
|
| 795 |
+
return self._discover_result.capabilities
|
| 796 |
+
if self._initialize_result is not None:
|
| 797 |
+
return self._initialize_result.capabilities
|
| 798 |
+
return None
|
| 799 |
+
|
| 800 |
+
@property
|
| 801 |
+
def instructions(self) -> str | None:
|
| 802 |
+
"""Server-provided instructions text, if any."""
|
| 803 |
+
if self._discover_result is not None:
|
| 804 |
+
return self._discover_result.instructions
|
| 805 |
+
if self._initialize_result is not None:
|
| 806 |
+
return self._initialize_result.instructions
|
| 807 |
+
return None
|
| 808 |
+
|
| 809 |
+
async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult:
|
| 810 |
+
"""Send a ping request."""
|
| 811 |
+
return await self.send_request(types.PingRequest(params=types.RequestParams(_meta=meta)), types.EmptyResult)
|
| 812 |
+
|
| 813 |
+
@deprecated(
|
| 814 |
+
"Client-to-server progress is deprecated as of 2026-07-28; progress is server-to-client only.",
|
| 815 |
+
category=MCPDeprecationWarning,
|
| 816 |
+
)
|
| 817 |
+
async def send_progress_notification(
|
| 818 |
+
self,
|
| 819 |
+
progress_token: str | int,
|
| 820 |
+
progress: float,
|
| 821 |
+
total: float | None = None,
|
| 822 |
+
message: str | None = None,
|
| 823 |
+
*,
|
| 824 |
+
meta: RequestParamsMeta | None = None,
|
| 825 |
+
) -> None:
|
| 826 |
+
"""Send a progress notification."""
|
| 827 |
+
await self.send_notification(
|
| 828 |
+
types.ProgressNotification(
|
| 829 |
+
params=types.ProgressNotificationParams(
|
| 830 |
+
progress_token=progress_token,
|
| 831 |
+
progress=progress,
|
| 832 |
+
total=total,
|
| 833 |
+
message=message,
|
| 834 |
+
_meta=meta,
|
| 835 |
+
),
|
| 836 |
+
)
|
| 837 |
+
)
|
| 838 |
+
|
| 839 |
+
@deprecated("The logging capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
|
| 840 |
+
async def set_logging_level(
|
| 841 |
+
self,
|
| 842 |
+
level: types.LoggingLevel,
|
| 843 |
+
*,
|
| 844 |
+
meta: RequestParamsMeta | None = None,
|
| 845 |
+
) -> types.EmptyResult:
|
| 846 |
+
"""Send a logging/setLevel request."""
|
| 847 |
+
return await self.send_request(
|
| 848 |
+
types.SetLevelRequest(params=types.SetLevelRequestParams(level=level, _meta=meta)),
|
| 849 |
+
types.EmptyResult,
|
| 850 |
+
)
|
| 851 |
+
|
| 852 |
+
async def list_resources(self, *, params: types.PaginatedRequestParams | None = None) -> types.ListResourcesResult:
|
| 853 |
+
"""Send a resources/list request.
|
| 854 |
+
|
| 855 |
+
Args:
|
| 856 |
+
params: Full pagination parameters including cursor and any future fields
|
| 857 |
+
"""
|
| 858 |
+
return await self.send_request(types.ListResourcesRequest(params=params), types.ListResourcesResult)
|
| 859 |
+
|
| 860 |
+
async def list_resource_templates(
|
| 861 |
+
self, *, params: types.PaginatedRequestParams | None = None
|
| 862 |
+
) -> types.ListResourceTemplatesResult:
|
| 863 |
+
"""Send a resources/templates/list request.
|
| 864 |
+
|
| 865 |
+
Args:
|
| 866 |
+
params: Full pagination parameters including cursor and any future fields
|
| 867 |
+
"""
|
| 868 |
+
return await self.send_request(
|
| 869 |
+
types.ListResourceTemplatesRequest(params=params),
|
| 870 |
+
types.ListResourceTemplatesResult,
|
| 871 |
+
)
|
| 872 |
+
|
| 873 |
+
@overload
|
| 874 |
+
async def read_resource(
|
| 875 |
+
self,
|
| 876 |
+
uri: str,
|
| 877 |
+
*,
|
| 878 |
+
input_responses: types.InputResponses | None = None,
|
| 879 |
+
request_state: str | None = None,
|
| 880 |
+
meta: RequestParamsMeta | None = None,
|
| 881 |
+
allow_input_required: Literal[False] = False,
|
| 882 |
+
) -> types.ReadResourceResult: ...
|
| 883 |
+
|
| 884 |
+
@overload
|
| 885 |
+
async def read_resource(
|
| 886 |
+
self,
|
| 887 |
+
uri: str,
|
| 888 |
+
*,
|
| 889 |
+
input_responses: types.InputResponses | None = None,
|
| 890 |
+
request_state: str | None = None,
|
| 891 |
+
meta: RequestParamsMeta | None = None,
|
| 892 |
+
allow_input_required: bool,
|
| 893 |
+
) -> types.ReadResourceResult | types.InputRequiredResult: ...
|
| 894 |
+
|
| 895 |
+
async def read_resource(
|
| 896 |
+
self,
|
| 897 |
+
uri: str,
|
| 898 |
+
*,
|
| 899 |
+
input_responses: types.InputResponses | None = None,
|
| 900 |
+
request_state: str | None = None,
|
| 901 |
+
meta: RequestParamsMeta | None = None,
|
| 902 |
+
allow_input_required: bool = False,
|
| 903 |
+
) -> types.ReadResourceResult | types.InputRequiredResult:
|
| 904 |
+
"""Send a resources/read request.
|
| 905 |
+
|
| 906 |
+
Args:
|
| 907 |
+
input_responses: Responses to a prior `InputRequiredResult.input_requests`.
|
| 908 |
+
request_state: Opaque state echoed from a prior `InputRequiredResult`.
|
| 909 |
+
allow_input_required: When `False` (default), an `InputRequiredResult`
|
| 910 |
+
from the server raises `RuntimeError`; when `True`, it is returned
|
| 911 |
+
so the caller can resolve the requests and retry.
|
| 912 |
+
|
| 913 |
+
Raises:
|
| 914 |
+
RuntimeError: If the server returns an `InputRequiredResult` and
|
| 915 |
+
`allow_input_required` is `False`.
|
| 916 |
+
"""
|
| 917 |
+
result = await self.send_request(
|
| 918 |
+
types.ReadResourceRequest(
|
| 919 |
+
params=types.ReadResourceRequestParams(
|
| 920 |
+
uri=uri,
|
| 921 |
+
input_responses=input_responses,
|
| 922 |
+
request_state=request_state,
|
| 923 |
+
_meta=meta,
|
| 924 |
+
),
|
| 925 |
+
),
|
| 926 |
+
_ReadResourceResultAdapter,
|
| 927 |
+
)
|
| 928 |
+
if isinstance(result, types.InputRequiredResult) and not allow_input_required:
|
| 929 |
+
raise _input_required_unexpected("read_resource")
|
| 930 |
+
return result
|
| 931 |
+
|
| 932 |
+
@deprecated(
|
| 933 |
+
"resources/subscribe is removed as of 2026-07-28; use Client.listen() instead.",
|
| 934 |
+
category=MCPDeprecationWarning,
|
| 935 |
+
)
|
| 936 |
+
async def subscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult:
|
| 937 |
+
"""Send a resources/subscribe request (2025-era servers only)."""
|
| 938 |
+
return await self.send_request(
|
| 939 |
+
types.SubscribeRequest(params=types.SubscribeRequestParams(uri=uri, _meta=meta)),
|
| 940 |
+
types.EmptyResult,
|
| 941 |
+
)
|
| 942 |
+
|
| 943 |
+
@deprecated(
|
| 944 |
+
"resources/unsubscribe is removed as of 2026-07-28; use Client.listen() instead.",
|
| 945 |
+
category=MCPDeprecationWarning,
|
| 946 |
+
)
|
| 947 |
+
async def unsubscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult:
|
| 948 |
+
"""Send a resources/unsubscribe request (2025-era servers only)."""
|
| 949 |
+
return await self.send_request(
|
| 950 |
+
types.UnsubscribeRequest(params=types.UnsubscribeRequestParams(uri=uri, _meta=meta)),
|
| 951 |
+
types.EmptyResult,
|
| 952 |
+
)
|
| 953 |
+
|
| 954 |
+
@overload
|
| 955 |
+
async def call_tool(
|
| 956 |
+
self,
|
| 957 |
+
name: str,
|
| 958 |
+
arguments: dict[str, Any] | None = None,
|
| 959 |
+
read_timeout_seconds: float | None = None,
|
| 960 |
+
progress_callback: ProgressFnT | None = None,
|
| 961 |
+
*,
|
| 962 |
+
input_responses: types.InputResponses | None = None,
|
| 963 |
+
request_state: str | None = None,
|
| 964 |
+
meta: RequestParamsMeta | None = None,
|
| 965 |
+
allow_input_required: Literal[False] = False,
|
| 966 |
+
allow_claimed: Literal[False] = False,
|
| 967 |
+
) -> types.CallToolResult: ...
|
| 968 |
+
|
| 969 |
+
@overload
|
| 970 |
+
async def call_tool(
|
| 971 |
+
self,
|
| 972 |
+
name: str,
|
| 973 |
+
arguments: dict[str, Any] | None = None,
|
| 974 |
+
read_timeout_seconds: float | None = None,
|
| 975 |
+
progress_callback: ProgressFnT | None = None,
|
| 976 |
+
*,
|
| 977 |
+
input_responses: types.InputResponses | None = None,
|
| 978 |
+
request_state: str | None = None,
|
| 979 |
+
meta: RequestParamsMeta | None = None,
|
| 980 |
+
allow_input_required: bool,
|
| 981 |
+
allow_claimed: Literal[False] = False,
|
| 982 |
+
) -> types.CallToolResult | types.InputRequiredResult: ...
|
| 983 |
+
|
| 984 |
+
@overload
|
| 985 |
+
async def call_tool(
|
| 986 |
+
self,
|
| 987 |
+
name: str,
|
| 988 |
+
arguments: dict[str, Any] | None = None,
|
| 989 |
+
read_timeout_seconds: float | None = None,
|
| 990 |
+
progress_callback: ProgressFnT | None = None,
|
| 991 |
+
*,
|
| 992 |
+
input_responses: types.InputResponses | None = None,
|
| 993 |
+
request_state: str | None = None,
|
| 994 |
+
meta: RequestParamsMeta | None = None,
|
| 995 |
+
allow_input_required: Literal[False] = False,
|
| 996 |
+
allow_claimed: bool,
|
| 997 |
+
) -> types.CallToolResult | types.Result: ...
|
| 998 |
+
|
| 999 |
+
@overload
|
| 1000 |
+
async def call_tool(
|
| 1001 |
+
self,
|
| 1002 |
+
name: str,
|
| 1003 |
+
arguments: dict[str, Any] | None = None,
|
| 1004 |
+
read_timeout_seconds: float | None = None,
|
| 1005 |
+
progress_callback: ProgressFnT | None = None,
|
| 1006 |
+
*,
|
| 1007 |
+
input_responses: types.InputResponses | None = None,
|
| 1008 |
+
request_state: str | None = None,
|
| 1009 |
+
meta: RequestParamsMeta | None = None,
|
| 1010 |
+
allow_input_required: bool,
|
| 1011 |
+
allow_claimed: bool,
|
| 1012 |
+
) -> types.CallToolResult | types.InputRequiredResult | types.Result: ...
|
| 1013 |
+
|
| 1014 |
+
async def call_tool(
|
| 1015 |
+
self,
|
| 1016 |
+
name: str,
|
| 1017 |
+
arguments: dict[str, Any] | None = None,
|
| 1018 |
+
read_timeout_seconds: float | None = None,
|
| 1019 |
+
progress_callback: ProgressFnT | None = None,
|
| 1020 |
+
*,
|
| 1021 |
+
input_responses: types.InputResponses | None = None,
|
| 1022 |
+
request_state: str | None = None,
|
| 1023 |
+
meta: RequestParamsMeta | None = None,
|
| 1024 |
+
allow_input_required: bool = False,
|
| 1025 |
+
allow_claimed: bool = False,
|
| 1026 |
+
) -> types.CallToolResult | types.InputRequiredResult | types.Result:
|
| 1027 |
+
"""Send a tools/call request with optional progress callback support.
|
| 1028 |
+
|
| 1029 |
+
On a modern (2026-07-28) connection, arguments annotated with `x-mcp-header`
|
| 1030 |
+
in the tool's input schema are mirrored into `Mcp-Param-*` request headers.
|
| 1031 |
+
The annotations are read from the tool's last `list_tools` entry, so list
|
| 1032 |
+
the tool before calling it to enable header emission.
|
| 1033 |
+
|
| 1034 |
+
Args:
|
| 1035 |
+
input_responses: Responses to a prior `InputRequiredResult.input_requests`.
|
| 1036 |
+
request_state: Opaque state echoed from a prior `InputRequiredResult`.
|
| 1037 |
+
allow_input_required: When ``False`` (default), an `InputRequiredResult`
|
| 1038 |
+
from the server raises `RuntimeError`; when ``True``, it is returned
|
| 1039 |
+
so the caller can resolve the requests and retry.
|
| 1040 |
+
allow_claimed: When `False` (default), a claimed extension result raises
|
| 1041 |
+
`UnexpectedClaimedResult`; when `True`, the parsed claim model is returned.
|
| 1042 |
+
|
| 1043 |
+
Raises:
|
| 1044 |
+
RuntimeError: If the server returns an `InputRequiredResult` and
|
| 1045 |
+
``allow_input_required`` is ``False``.
|
| 1046 |
+
UnexpectedClaimedResult: Claimed result with `allow_claimed` False; carries the parsed value.
|
| 1047 |
+
"""
|
| 1048 |
+
result = await self.send_request(
|
| 1049 |
+
types.CallToolRequest(
|
| 1050 |
+
params=types.CallToolRequestParams(
|
| 1051 |
+
name=name,
|
| 1052 |
+
arguments=arguments,
|
| 1053 |
+
input_responses=input_responses,
|
| 1054 |
+
request_state=request_state,
|
| 1055 |
+
_meta=meta,
|
| 1056 |
+
),
|
| 1057 |
+
),
|
| 1058 |
+
self._call_tool_adapter,
|
| 1059 |
+
request_read_timeout_seconds=read_timeout_seconds,
|
| 1060 |
+
progress_callback=progress_callback,
|
| 1061 |
+
)
|
| 1062 |
+
|
| 1063 |
+
if isinstance(result, types.CallToolResult) and not result.is_error:
|
| 1064 |
+
await self.validate_tool_result(name, result)
|
| 1065 |
+
|
| 1066 |
+
# The input_required arm stays first; a claimed shape is terminal for the multi-round-trip driver.
|
| 1067 |
+
if isinstance(result, types.InputRequiredResult) and not allow_input_required:
|
| 1068 |
+
raise _input_required_unexpected("call_tool")
|
| 1069 |
+
if not isinstance(result, types.CallToolResult | types.InputRequiredResult) and not allow_claimed:
|
| 1070 |
+
raise UnexpectedClaimedResult(result)
|
| 1071 |
+
return result
|
| 1072 |
+
|
| 1073 |
+
def _resolve_param_headers(self, name: str, arguments: Mapping[str, Any]) -> dict[str, str]:
|
| 1074 |
+
"""`Mcp-Param-*` headers for a `tools/call`, or empty when the tool was never listed."""
|
| 1075 |
+
header_map = self._x_mcp_header_maps.get(name)
|
| 1076 |
+
if header_map is None:
|
| 1077 |
+
return {}
|
| 1078 |
+
return mcp_param_headers(header_map, arguments)
|
| 1079 |
+
|
| 1080 |
+
async def validate_tool_result(self, name: str, result: types.CallToolResult) -> None:
|
| 1081 |
+
"""Revalidate a `CallToolResult` against the tool's declared output schema.
|
| 1082 |
+
|
| 1083 |
+
Raises:
|
| 1084 |
+
RuntimeError: Structured content is missing or does not conform to the schema.
|
| 1085 |
+
"""
|
| 1086 |
+
if name not in self._tool_output_schemas:
|
| 1087 |
+
# refresh output schema cache
|
| 1088 |
+
await self.list_tools()
|
| 1089 |
+
|
| 1090 |
+
output_schema = None
|
| 1091 |
+
if name in self._tool_output_schemas:
|
| 1092 |
+
output_schema = self._tool_output_schemas.get(name)
|
| 1093 |
+
else:
|
| 1094 |
+
logger.warning(f"Tool {name} not listed by server, cannot validate any structured content")
|
| 1095 |
+
|
| 1096 |
+
if output_schema is not None:
|
| 1097 |
+
from jsonschema import exceptions as jsonschema_exceptions
|
| 1098 |
+
|
| 1099 |
+
if result.structured_content is None:
|
| 1100 |
+
raise RuntimeError(f"Tool {name} has an output schema but did not return structured content")
|
| 1101 |
+
validator = self._output_schema_validator(name, output_schema)
|
| 1102 |
+
# `best_match` picks the same error the previous `jsonschema.validate()` call raised,
|
| 1103 |
+
# so the message a caller sees is unchanged. It is untyped upstream.
|
| 1104 |
+
errors = validator.iter_errors(result.structured_content)
|
| 1105 |
+
error = cast(
|
| 1106 |
+
"Exception | None",
|
| 1107 |
+
jsonschema_exceptions.best_match(errors), # pyright: ignore[reportUnknownMemberType]
|
| 1108 |
+
)
|
| 1109 |
+
if error is not None:
|
| 1110 |
+
raise RuntimeError(f"Invalid structured content returned by tool {name}: {error}") from error
|
| 1111 |
+
|
| 1112 |
+
def _output_schema_validator(self, name: str, output_schema: dict[str, Any]) -> Validator:
|
| 1113 |
+
"""Compiled validator for the tool's cached output schema, built once per schema value.
|
| 1114 |
+
|
| 1115 |
+
Compiling is ~60x the cost of validating, so a one-shot `jsonschema.validate()` per
|
| 1116 |
+
result dominates `call_tool`; the compiled validator is cached instead. It stays valid
|
| 1117 |
+
because `_absorb_tool_listing` evicts a tool's validator whenever it absorbs a different
|
| 1118 |
+
schema for that tool, so a cached entry always matches `output_schema`.
|
| 1119 |
+
|
| 1120 |
+
Raises:
|
| 1121 |
+
RuntimeError: The schema is not a valid JSON Schema. Raised on every call, since a
|
| 1122 |
+
failed compile is never cached.
|
| 1123 |
+
"""
|
| 1124 |
+
from jsonschema import SchemaError
|
| 1125 |
+
from jsonschema.validators import validator_for
|
| 1126 |
+
|
| 1127 |
+
if (validator := self._tool_output_validators.get(name)) is not None:
|
| 1128 |
+
return validator
|
| 1129 |
+
|
| 1130 |
+
validator_cls = validator_for(output_schema)
|
| 1131 |
+
try:
|
| 1132 |
+
validator_cls.check_schema(output_schema)
|
| 1133 |
+
except SchemaError as e:
|
| 1134 |
+
raise RuntimeError(f"Invalid schema for tool {name}: {e}")
|
| 1135 |
+
# jsonschema ships no `py.typed`, so pyright reads typeshed's stub, which declares
|
| 1136 |
+
# `registry` as required (concrete validators default it); cast to a schema-only ctor.
|
| 1137 |
+
validator = cast("Callable[[dict[str, Any]], Validator]", validator_cls)(output_schema)
|
| 1138 |
+
self._tool_output_validators[name] = validator
|
| 1139 |
+
return validator
|
| 1140 |
+
|
| 1141 |
+
async def list_prompts(self, *, params: types.PaginatedRequestParams | None = None) -> types.ListPromptsResult:
|
| 1142 |
+
"""Send a prompts/list request.
|
| 1143 |
+
|
| 1144 |
+
Args:
|
| 1145 |
+
params: Full pagination parameters including cursor and any future fields
|
| 1146 |
+
"""
|
| 1147 |
+
return await self.send_request(types.ListPromptsRequest(params=params), types.ListPromptsResult)
|
| 1148 |
+
|
| 1149 |
+
@overload
|
| 1150 |
+
async def get_prompt(
|
| 1151 |
+
self,
|
| 1152 |
+
name: str,
|
| 1153 |
+
arguments: dict[str, str] | None = None,
|
| 1154 |
+
*,
|
| 1155 |
+
input_responses: types.InputResponses | None = None,
|
| 1156 |
+
request_state: str | None = None,
|
| 1157 |
+
meta: RequestParamsMeta | None = None,
|
| 1158 |
+
allow_input_required: Literal[False] = False,
|
| 1159 |
+
) -> types.GetPromptResult: ...
|
| 1160 |
+
|
| 1161 |
+
@overload
|
| 1162 |
+
async def get_prompt(
|
| 1163 |
+
self,
|
| 1164 |
+
name: str,
|
| 1165 |
+
arguments: dict[str, str] | None = None,
|
| 1166 |
+
*,
|
| 1167 |
+
input_responses: types.InputResponses | None = None,
|
| 1168 |
+
request_state: str | None = None,
|
| 1169 |
+
meta: RequestParamsMeta | None = None,
|
| 1170 |
+
allow_input_required: bool,
|
| 1171 |
+
) -> types.GetPromptResult | types.InputRequiredResult: ...
|
| 1172 |
+
|
| 1173 |
+
async def get_prompt(
|
| 1174 |
+
self,
|
| 1175 |
+
name: str,
|
| 1176 |
+
arguments: dict[str, str] | None = None,
|
| 1177 |
+
*,
|
| 1178 |
+
input_responses: types.InputResponses | None = None,
|
| 1179 |
+
request_state: str | None = None,
|
| 1180 |
+
meta: RequestParamsMeta | None = None,
|
| 1181 |
+
allow_input_required: bool = False,
|
| 1182 |
+
) -> types.GetPromptResult | types.InputRequiredResult:
|
| 1183 |
+
"""Send a prompts/get request.
|
| 1184 |
+
|
| 1185 |
+
Args:
|
| 1186 |
+
input_responses: Responses to a prior `InputRequiredResult.input_requests`.
|
| 1187 |
+
request_state: Opaque state echoed from a prior `InputRequiredResult`.
|
| 1188 |
+
allow_input_required: When `False` (default), an `InputRequiredResult`
|
| 1189 |
+
from the server raises `RuntimeError`; when `True`, it is returned
|
| 1190 |
+
so the caller can resolve the requests and retry.
|
| 1191 |
+
|
| 1192 |
+
Raises:
|
| 1193 |
+
RuntimeError: If the server returns an `InputRequiredResult` and
|
| 1194 |
+
`allow_input_required` is `False`.
|
| 1195 |
+
"""
|
| 1196 |
+
result = await self.send_request(
|
| 1197 |
+
types.GetPromptRequest(
|
| 1198 |
+
params=types.GetPromptRequestParams(
|
| 1199 |
+
name=name,
|
| 1200 |
+
arguments=arguments,
|
| 1201 |
+
input_responses=input_responses,
|
| 1202 |
+
request_state=request_state,
|
| 1203 |
+
_meta=meta,
|
| 1204 |
+
),
|
| 1205 |
+
),
|
| 1206 |
+
_GetPromptResultAdapter,
|
| 1207 |
+
)
|
| 1208 |
+
if isinstance(result, types.InputRequiredResult) and not allow_input_required:
|
| 1209 |
+
raise _input_required_unexpected("get_prompt")
|
| 1210 |
+
return result
|
| 1211 |
+
|
| 1212 |
+
async def complete(
|
| 1213 |
+
self,
|
| 1214 |
+
ref: types.ResourceTemplateReference | types.PromptReference,
|
| 1215 |
+
argument: dict[str, str],
|
| 1216 |
+
context_arguments: dict[str, str] | None = None,
|
| 1217 |
+
) -> types.CompleteResult:
|
| 1218 |
+
"""Send a completion/complete request."""
|
| 1219 |
+
context = None
|
| 1220 |
+
if context_arguments is not None:
|
| 1221 |
+
context = types.CompletionContext(arguments=context_arguments)
|
| 1222 |
+
|
| 1223 |
+
return await self.send_request(
|
| 1224 |
+
types.CompleteRequest(
|
| 1225 |
+
params=types.CompleteRequestParams(
|
| 1226 |
+
ref=ref,
|
| 1227 |
+
argument=types.CompletionArgument(**argument),
|
| 1228 |
+
context=context,
|
| 1229 |
+
),
|
| 1230 |
+
),
|
| 1231 |
+
types.CompleteResult,
|
| 1232 |
+
)
|
| 1233 |
+
|
| 1234 |
+
async def list_tools(self, *, params: types.PaginatedRequestParams | None = None) -> types.ListToolsResult:
|
| 1235 |
+
"""Send a tools/list request.
|
| 1236 |
+
|
| 1237 |
+
Args:
|
| 1238 |
+
params: Full pagination parameters including cursor and any future fields
|
| 1239 |
+
"""
|
| 1240 |
+
result = await self.send_request(
|
| 1241 |
+
types.ListToolsRequest(params=params),
|
| 1242 |
+
types.ListToolsResult,
|
| 1243 |
+
)
|
| 1244 |
+
complete = (params is None or params.cursor is None) and result.next_cursor is None
|
| 1245 |
+
return self._absorb_tool_listing(result, complete=complete)
|
| 1246 |
+
|
| 1247 |
+
def _absorb_tool_listing(self, result: types.ListToolsResult, *, complete: bool) -> types.ListToolsResult:
|
| 1248 |
+
"""Filter the listing per the 2026 x-mcp-header MUST and rebuild derived per-tool state, in place.
|
| 1249 |
+
|
| 1250 |
+
Idempotent: cached values are already post-filter, so the response cache can re-absorb a served listing.
|
| 1251 |
+
`complete` (an uncursored single-page listing) prunes per-tool state down to the listing's tools.
|
| 1252 |
+
"""
|
| 1253 |
+
if self._negotiated_version in MODERN_PROTOCOL_VERSIONS:
|
| 1254 |
+
# 2026-07-28: clients MUST drop tools whose x-mcp-header annotations are invalid.
|
| 1255 |
+
kept: list[types.Tool] = []
|
| 1256 |
+
for tool in result.tools:
|
| 1257 |
+
if (reason := find_invalid_x_mcp_header(tool.input_schema)) is not None:
|
| 1258 |
+
logger.warning("dropping tool %r: invalid x-mcp-header (%s)", tool.name, reason)
|
| 1259 |
+
# Evict any map cached from a prior valid listing so a stale entry can't
|
| 1260 |
+
# mirror headers for a tool this listing dropped.
|
| 1261 |
+
self._x_mcp_header_maps.pop(tool.name, None)
|
| 1262 |
+
continue
|
| 1263 |
+
# Cache the arg→header map so a later tools/call mirrors it into Mcp-Param-* headers.
|
| 1264 |
+
self._x_mcp_header_maps[tool.name] = x_mcp_header_map(tool.input_schema)
|
| 1265 |
+
kept.append(tool)
|
| 1266 |
+
result.tools = kept
|
| 1267 |
+
|
| 1268 |
+
# Cache tool output schemas for future validation; cursor pages only ever add. A
|
| 1269 |
+
# changed schema evicts its compiled validator; an unchanged one (a re-listing, or the
|
| 1270 |
+
# response cache re-absorbing a served hit) keeps it. Only validated tools pay the check.
|
| 1271 |
+
for tool in result.tools:
|
| 1272 |
+
if tool.name in self._tool_output_validators and not _same_schema(
|
| 1273 |
+
self._tool_output_schemas.get(tool.name), tool.output_schema
|
| 1274 |
+
):
|
| 1275 |
+
del self._tool_output_validators[tool.name]
|
| 1276 |
+
self._tool_output_schemas[tool.name] = tool.output_schema
|
| 1277 |
+
|
| 1278 |
+
if complete:
|
| 1279 |
+
# The listing is the full tool universe, so state for unlisted tools is stale
|
| 1280 |
+
# (the server dropped them, or a shared-cache writer's filter did).
|
| 1281 |
+
names = {tool.name for tool in result.tools}
|
| 1282 |
+
self._x_mcp_header_maps = {k: v for k, v in self._x_mcp_header_maps.items() if k in names}
|
| 1283 |
+
self._tool_output_schemas = {k: v for k, v in self._tool_output_schemas.items() if k in names}
|
| 1284 |
+
self._tool_output_validators = {k: v for k, v in self._tool_output_validators.items() if k in names}
|
| 1285 |
+
|
| 1286 |
+
return result
|
| 1287 |
+
|
| 1288 |
+
@deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
|
| 1289 |
+
async def send_roots_list_changed(self) -> None:
|
| 1290 |
+
"""Send a roots/list_changed notification."""
|
| 1291 |
+
await self.send_notification(types.RootsListChangedNotification())
|
| 1292 |
+
|
| 1293 |
+
async def _on_request(
|
| 1294 |
+
self, dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
| 1295 |
+
) -> dict[str, Any]:
|
| 1296 |
+
"""Answer a server-initiated request via the registered callbacks."""
|
| 1297 |
+
# Literal, not LATEST_PROTOCOL_VERSION: the fallback covers the initialize
|
| 1298 |
+
# handshake (which only exists at <=2025) and stateless until the header
|
| 1299 |
+
# is plumbed; its meaning is fixed regardless of LATEST bumps.
|
| 1300 |
+
version = self._negotiated_version or "2025-11-25"
|
| 1301 |
+
try:
|
| 1302 |
+
request = cast(types.ServerRequest, _methods.parse_server_request(method, version, params))
|
| 1303 |
+
except KeyError:
|
| 1304 |
+
raise MCPError(code=METHOD_NOT_FOUND, message="Method not found", data=method) from None
|
| 1305 |
+
|
| 1306 |
+
response: types.ClientResult | types.ErrorData
|
| 1307 |
+
if isinstance(request, types.PingRequest):
|
| 1308 |
+
# Answered without a context: ping has no callback that would need one.
|
| 1309 |
+
response = types.EmptyResult()
|
| 1310 |
+
else:
|
| 1311 |
+
assert dctx.request_id is not None # the callback-driving dispatchers always assign ids
|
| 1312 |
+
ctx = ClientRequestContext(
|
| 1313 |
+
session=self, request_id=dctx.request_id, meta=request.params.meta if request.params else None
|
| 1314 |
+
)
|
| 1315 |
+
response = await self.dispatch_input_request(ctx, request)
|
| 1316 |
+
client_response = ClientResponse.validate_python(response)
|
| 1317 |
+
if isinstance(client_response, types.ErrorData):
|
| 1318 |
+
raise MCPError.from_error_data(client_response)
|
| 1319 |
+
dumped = client_response.model_dump(by_alias=True, mode="json", exclude_none=True)
|
| 1320 |
+
try:
|
| 1321 |
+
_methods.validate_client_result(method, version, dumped)
|
| 1322 |
+
except ValidationError:
|
| 1323 |
+
logger.exception("client callback for %r returned an invalid result", method)
|
| 1324 |
+
raise MCPError(code=INTERNAL_ERROR, message="Client callback returned an invalid result") from None
|
| 1325 |
+
return dumped
|
| 1326 |
+
|
| 1327 |
+
async def dispatch_input_request(
|
| 1328 |
+
self, ctx: ClientRequestContext, request: types.InputRequest
|
| 1329 |
+
) -> types.InputResponse | types.ErrorData:
|
| 1330 |
+
"""Route an input request through the client's callback table.
|
| 1331 |
+
|
| 1332 |
+
Shared by the legacy server→client RPC path (`_on_request`) and the
|
| 1333 |
+
2026-07-28 multi-round-trip driver, which dispatches the embedded
|
| 1334 |
+
`InputRequiredResult.input_requests` through the same callbacks.
|
| 1335 |
+
|
| 1336 |
+
Returns the callback's `InputResponse`, or `ErrorData` when the callback declines.
|
| 1337 |
+
"""
|
| 1338 |
+
match request:
|
| 1339 |
+
case types.CreateMessageRequest(params=p):
|
| 1340 |
+
return await self._sampling_callback(ctx, p)
|
| 1341 |
+
case types.ElicitRequest(params=p):
|
| 1342 |
+
return await self._elicitation_callback(ctx, p)
|
| 1343 |
+
case types.ListRootsRequest(): # pragma: no branch
|
| 1344 |
+
return await self._list_roots_callback(ctx)
|
| 1345 |
+
|
| 1346 |
+
def _register_listen_route(self, request_id: RequestId) -> ListenRoute:
|
| 1347 |
+
"""Create the demux route for a listen request id; the caller registers BEFORE sending."""
|
| 1348 |
+
route = ListenRoute()
|
| 1349 |
+
self._listen_routes[request_id] = route
|
| 1350 |
+
return route
|
| 1351 |
+
|
| 1352 |
+
def _unregister_listen_route(self, request_id: RequestId) -> None:
|
| 1353 |
+
"""Drop a listen route; the handle owns membership, so a missing key is a no-op."""
|
| 1354 |
+
self._listen_routes.pop(request_id, None)
|
| 1355 |
+
|
| 1356 |
+
def _settle_listen_routes_closed(self) -> None:
|
| 1357 |
+
"""Settle all open listen routes as lost on session exit; cancelled driver tasks cannot."""
|
| 1358 |
+
closed = MCPError(code=CONNECTION_CLOSED, message="Connection closed")
|
| 1359 |
+
for route in self._listen_routes.values():
|
| 1360 |
+
route.settle("lost", error=closed)
|
| 1361 |
+
self._listen_routes.clear()
|
| 1362 |
+
|
| 1363 |
+
def _intercept_notification(self, method: str, params: Mapping[str, Any] | None) -> bool:
|
| 1364 |
+
"""Wire-order listen demux, run synchronously on the dispatcher's receive path.
|
| 1365 |
+
|
| 1366 |
+
Bookkeeping must advance in receive order with the listen result (resolved on
|
| 1367 |
+
this same path); the spawned `_on_notify` path would race it and drop events.
|
| 1368 |
+
Returns True to consume the frame: a live route's ack is driver state, never surfaced.
|
| 1369 |
+
"""
|
| 1370 |
+
if not self._listen_routes:
|
| 1371 |
+
return False
|
| 1372 |
+
if method == "notifications/cancelled":
|
| 1373 |
+
request_id = cancelled_request_id_from_params(params)
|
| 1374 |
+
if request_id is not None and (listen_route := self._listen_routes.get(request_id)) is not None:
|
| 1375 |
+
# a server-sent cancel naming a listen request is that stream's teardown signal
|
| 1376 |
+
listen_route.settle("lost")
|
| 1377 |
+
return False # _on_notify swallows every cancelled either way (v1 parity)
|
| 1378 |
+
if params is None:
|
| 1379 |
+
return False
|
| 1380 |
+
meta = params.get("_meta")
|
| 1381 |
+
if not isinstance(meta, Mapping):
|
| 1382 |
+
return False
|
| 1383 |
+
# as_request_id is not a tripwire: raw wire _meta can carry a non-id (even unhashable) value
|
| 1384 |
+
subscription_id = as_request_id(cast("Mapping[str, Any]", meta).get(SUBSCRIPTION_ID_META_KEY))
|
| 1385 |
+
if subscription_id is None or (listen_route := self._listen_routes.get(subscription_id)) is None:
|
| 1386 |
+
return False
|
| 1387 |
+
if method == "notifications/subscriptions/acknowledged":
|
| 1388 |
+
raw_filter = params.get("notifications")
|
| 1389 |
+
if raw_filter is None:
|
| 1390 |
+
# malformed, not an empty filter: leave it to the spawned path's validation warning
|
| 1391 |
+
return False
|
| 1392 |
+
try:
|
| 1393 |
+
honored = types.SubscriptionFilter.model_validate(raw_filter)
|
| 1394 |
+
except ValidationError:
|
| 1395 |
+
return False
|
| 1396 |
+
listen_route.set_acked(honored)
|
| 1397 |
+
return True
|
| 1398 |
+
if (event := event_from_wire(method, params)) is not None:
|
| 1399 |
+
listen_route.deliver(event)
|
| 1400 |
+
return False # events (and any other stamped frame) still tee as usual
|
| 1401 |
+
|
| 1402 |
+
async def _on_notify(
|
| 1403 |
+
self, dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
| 1404 |
+
) -> None:
|
| 1405 |
+
"""Route a server notification: validate, run the typed callback, tee to message_handler."""
|
| 1406 |
+
# Same fallback as `_on_request`: covers pre-handshake and stateless.
|
| 1407 |
+
version = self._negotiated_version or "2025-11-25"
|
| 1408 |
+
try:
|
| 1409 |
+
notification = cast(types.ServerNotification, _methods.parse_server_notification(method, version, params))
|
| 1410 |
+
except KeyError:
|
| 1411 |
+
# Only methods unknown to the negotiated version's core tables reach the bindings.
|
| 1412 |
+
binding = self._notification_bindings.get(method)
|
| 1413 |
+
if binding is None:
|
| 1414 |
+
logger.debug("dropped %r: not defined at %s", method, version)
|
| 1415 |
+
return
|
| 1416 |
+
try:
|
| 1417 |
+
bound_params = binding.params_type.model_validate(params or {})
|
| 1418 |
+
except ValidationError:
|
| 1419 |
+
logger.warning("Failed to validate notification: %s", method, exc_info=True)
|
| 1420 |
+
return
|
| 1421 |
+
send, receive = self._binding_queues[method]
|
| 1422 |
+
try:
|
| 1423 |
+
# Must not await: DirectDispatcher calls _on_notify inline; blocking deadlocks in-process servers.
|
| 1424 |
+
send.send_nowait(bound_params)
|
| 1425 |
+
except anyio.WouldBlock:
|
| 1426 |
+
# Evict the oldest event; no checkpoint since the failed send,
|
| 1427 |
+
# so the buffer is still full and the retry cannot block.
|
| 1428 |
+
receive.receive_nowait()
|
| 1429 |
+
logger.warning("notification queue for %r is full; dropped the oldest event", method)
|
| 1430 |
+
send.send_nowait(bound_params)
|
| 1431 |
+
return
|
| 1432 |
+
except ValidationError:
|
| 1433 |
+
logger.warning("Failed to validate notification: %s", method, exc_info=True)
|
| 1434 |
+
return
|
| 1435 |
+
if isinstance(notification, types.CancelledNotification):
|
| 1436 |
+
# Never surfaced (v1 parity): the dispatcher already applied it; listen cancels settled by the intercept.
|
| 1437 |
+
return
|
| 1438 |
+
try:
|
| 1439 |
+
if isinstance(notification, types.LoggingMessageNotification):
|
| 1440 |
+
await self._logging_callback(notification.params)
|
| 1441 |
+
await self._message_handler(notification)
|
| 1442 |
+
except Exception:
|
| 1443 |
+
# Contain here, not in the dispatcher: DirectDispatcher awaits this
|
| 1444 |
+
# handler inline in the peer's notify() call, so a raising callback
|
| 1445 |
+
# would otherwise fail the peer's send. A raising logging_callback
|
| 1446 |
+
# skips the message_handler tee for that notification (v1 parity).
|
| 1447 |
+
logger.exception("notification callback for %r raised", method)
|
| 1448 |
+
|
| 1449 |
+
async def _on_stream_exception(self, exc: Exception) -> None:
|
| 1450 |
+
"""Deliver a transport-level fault to message_handler via a spawned task.
|
| 1451 |
+
|
| 1452 |
+
Running the handler inline would park the dispatcher's read loop and
|
| 1453 |
+
deadlock handlers that await session I/O.
|
| 1454 |
+
"""
|
| 1455 |
+
assert self._task_group is not None
|
| 1456 |
+
self._task_group.start_soon(self._deliver_stream_exception, exc)
|
| 1457 |
+
|
| 1458 |
+
async def _deliver_stream_exception(self, exc: Exception) -> None:
|
| 1459 |
+
try:
|
| 1460 |
+
await self._message_handler(exc)
|
| 1461 |
+
except Exception:
|
| 1462 |
+
logger.exception("message_handler raised on transport exception")
|
.mcp-venv/lib/python3.13/site-packages/mcp/client/session_group.py
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SessionGroup concurrently manages multiple MCP session connections.
|
| 2 |
+
|
| 3 |
+
Tools, resources, and prompts are aggregated across servers. Servers may
|
| 4 |
+
be connected to or disconnected from at any point after initialization.
|
| 5 |
+
|
| 6 |
+
This abstraction can handle naming collisions using a custom user-provided hook.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import contextlib
|
| 10 |
+
import logging
|
| 11 |
+
from collections.abc import Callable
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
from types import TracebackType
|
| 14 |
+
from typing import Any, Literal, TypeAlias, overload
|
| 15 |
+
|
| 16 |
+
import anyio
|
| 17 |
+
import httpx2
|
| 18 |
+
import mcp_types as types
|
| 19 |
+
from pydantic import BaseModel, Field
|
| 20 |
+
from typing_extensions import Self
|
| 21 |
+
|
| 22 |
+
import mcp
|
| 23 |
+
from mcp.client.session import ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
|
| 24 |
+
from mcp.client.sse import sse_client
|
| 25 |
+
from mcp.client.stdio import StdioServerParameters
|
| 26 |
+
from mcp.client.streamable_http import streamable_http_client
|
| 27 |
+
from mcp.shared._httpx_utils import create_mcp_http_client
|
| 28 |
+
from mcp.shared.dispatcher import ProgressFnT
|
| 29 |
+
from mcp.shared.exceptions import MCPError
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class SseServerParameters(BaseModel):
|
| 33 |
+
"""Parameters for initializing an sse_client."""
|
| 34 |
+
|
| 35 |
+
# The endpoint URL.
|
| 36 |
+
url: str
|
| 37 |
+
|
| 38 |
+
# Optional headers to include in requests.
|
| 39 |
+
headers: dict[str, Any] | None = None
|
| 40 |
+
|
| 41 |
+
# HTTP timeout for regular operations (in seconds).
|
| 42 |
+
timeout: float = 5.0
|
| 43 |
+
|
| 44 |
+
# Timeout for SSE read operations (in seconds).
|
| 45 |
+
sse_read_timeout: float = 300.0
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class StreamableHttpParameters(BaseModel):
|
| 49 |
+
"""Parameters for initializing a streamable_http_client."""
|
| 50 |
+
|
| 51 |
+
# The endpoint URL.
|
| 52 |
+
url: str
|
| 53 |
+
|
| 54 |
+
# Optional headers to include in requests.
|
| 55 |
+
headers: dict[str, Any] | None = None
|
| 56 |
+
|
| 57 |
+
# HTTP timeout for regular operations (in seconds).
|
| 58 |
+
timeout: float = 30.0
|
| 59 |
+
|
| 60 |
+
# Timeout for SSE read operations (in seconds).
|
| 61 |
+
sse_read_timeout: float = 300.0
|
| 62 |
+
|
| 63 |
+
# Close the client session when the transport closes.
|
| 64 |
+
terminate_on_close: bool = True
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
ServerParameters: TypeAlias = StdioServerParameters | SseServerParameters | StreamableHttpParameters
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# Use dataclass instead of Pydantic BaseModel
|
| 71 |
+
# because Pydantic BaseModel cannot handle Protocol fields.
|
| 72 |
+
@dataclass
|
| 73 |
+
class ClientSessionParameters:
|
| 74 |
+
"""Parameters for establishing a client session to an MCP server."""
|
| 75 |
+
|
| 76 |
+
read_timeout_seconds: float | None = None
|
| 77 |
+
sampling_callback: SamplingFnT | None = None
|
| 78 |
+
elicitation_callback: ElicitationFnT | None = None
|
| 79 |
+
list_roots_callback: ListRootsFnT | None = None
|
| 80 |
+
logging_callback: LoggingFnT | None = None
|
| 81 |
+
message_handler: MessageHandlerFnT | None = None
|
| 82 |
+
client_info: types.Implementation | None = None
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class ClientSessionGroup:
|
| 86 |
+
"""Client for managing connections to multiple MCP servers.
|
| 87 |
+
|
| 88 |
+
This class is responsible for encapsulating management of server connections.
|
| 89 |
+
It aggregates tools, resources, and prompts from all connected servers.
|
| 90 |
+
|
| 91 |
+
For auxiliary handlers, such as resource subscription, this is delegated to
|
| 92 |
+
the client and can be accessed via the session.
|
| 93 |
+
|
| 94 |
+
Example:
|
| 95 |
+
```python
|
| 96 |
+
name_fn = lambda name, server_info: f"{(server_info.name)}_{name}"
|
| 97 |
+
async with ClientSessionGroup(component_name_hook=name_fn) as group:
|
| 98 |
+
for server_param in server_params:
|
| 99 |
+
await group.connect_to_server(server_param)
|
| 100 |
+
...
|
| 101 |
+
```
|
| 102 |
+
"""
|
| 103 |
+
|
| 104 |
+
class _ComponentNames(BaseModel):
|
| 105 |
+
"""Used for reverse index to find components."""
|
| 106 |
+
|
| 107 |
+
prompts: set[str] = Field(default_factory=set)
|
| 108 |
+
resources: set[str] = Field(default_factory=set)
|
| 109 |
+
tools: set[str] = Field(default_factory=set)
|
| 110 |
+
|
| 111 |
+
# Standard MCP components.
|
| 112 |
+
_prompts: dict[str, types.Prompt]
|
| 113 |
+
_resources: dict[str, types.Resource]
|
| 114 |
+
_tools: dict[str, types.Tool]
|
| 115 |
+
|
| 116 |
+
# Client-server connection management.
|
| 117 |
+
_sessions: dict[mcp.ClientSession, _ComponentNames]
|
| 118 |
+
_tool_to_session: dict[str, mcp.ClientSession]
|
| 119 |
+
_exit_stack: contextlib.AsyncExitStack
|
| 120 |
+
_session_exit_stacks: dict[mcp.ClientSession, contextlib.AsyncExitStack]
|
| 121 |
+
|
| 122 |
+
# Optional fn consuming (component_name, server_info) for custom names.
|
| 123 |
+
# This is to provide a means to mitigate naming conflicts across servers.
|
| 124 |
+
# Example: (tool_name, server_info) => "{result.server_info.name}.{tool_name}"
|
| 125 |
+
_ComponentNameHook: TypeAlias = Callable[[str, types.Implementation], str]
|
| 126 |
+
_component_name_hook: _ComponentNameHook | None
|
| 127 |
+
|
| 128 |
+
def __init__(
|
| 129 |
+
self,
|
| 130 |
+
exit_stack: contextlib.AsyncExitStack | None = None,
|
| 131 |
+
component_name_hook: _ComponentNameHook | None = None,
|
| 132 |
+
) -> None:
|
| 133 |
+
"""Initializes the MCP client."""
|
| 134 |
+
|
| 135 |
+
self._tools = {}
|
| 136 |
+
self._resources = {}
|
| 137 |
+
self._prompts = {}
|
| 138 |
+
|
| 139 |
+
self._sessions = {}
|
| 140 |
+
self._tool_to_session = {}
|
| 141 |
+
if exit_stack is None:
|
| 142 |
+
self._exit_stack = contextlib.AsyncExitStack()
|
| 143 |
+
self._owns_exit_stack = True
|
| 144 |
+
else:
|
| 145 |
+
self._exit_stack = exit_stack
|
| 146 |
+
self._owns_exit_stack = False
|
| 147 |
+
self._session_exit_stacks = {}
|
| 148 |
+
self._component_name_hook = component_name_hook
|
| 149 |
+
|
| 150 |
+
async def __aenter__(self) -> Self: # pragma: no cover
|
| 151 |
+
# Enter the exit stack only if we created it ourselves
|
| 152 |
+
if self._owns_exit_stack:
|
| 153 |
+
await self._exit_stack.__aenter__()
|
| 154 |
+
return self
|
| 155 |
+
|
| 156 |
+
async def __aexit__(
|
| 157 |
+
self,
|
| 158 |
+
_exc_type: type[BaseException] | None,
|
| 159 |
+
_exc_val: BaseException | None,
|
| 160 |
+
_exc_tb: TracebackType | None,
|
| 161 |
+
) -> bool | None: # pragma: no cover
|
| 162 |
+
"""Closes session exit stacks and main exit stack upon completion."""
|
| 163 |
+
|
| 164 |
+
# Only close the main exit stack if we created it
|
| 165 |
+
if self._owns_exit_stack:
|
| 166 |
+
await self._exit_stack.aclose()
|
| 167 |
+
|
| 168 |
+
# Concurrently close session stacks.
|
| 169 |
+
async with anyio.create_task_group() as tg:
|
| 170 |
+
for exit_stack in self._session_exit_stacks.values():
|
| 171 |
+
tg.start_soon(exit_stack.aclose)
|
| 172 |
+
|
| 173 |
+
@property
|
| 174 |
+
def sessions(self) -> list[mcp.ClientSession]:
|
| 175 |
+
"""Returns the list of sessions being managed."""
|
| 176 |
+
return list(self._sessions.keys()) # pragma: no cover
|
| 177 |
+
|
| 178 |
+
@property
|
| 179 |
+
def prompts(self) -> dict[str, types.Prompt]:
|
| 180 |
+
"""Returns the prompts as a dictionary of names to prompts."""
|
| 181 |
+
return self._prompts
|
| 182 |
+
|
| 183 |
+
@property
|
| 184 |
+
def resources(self) -> dict[str, types.Resource]:
|
| 185 |
+
"""Returns the resources as a dictionary of names to resources."""
|
| 186 |
+
return self._resources
|
| 187 |
+
|
| 188 |
+
@property
|
| 189 |
+
def tools(self) -> dict[str, types.Tool]:
|
| 190 |
+
"""Returns the tools as a dictionary of names to tools."""
|
| 191 |
+
return self._tools
|
| 192 |
+
|
| 193 |
+
@overload
|
| 194 |
+
async def call_tool(
|
| 195 |
+
self,
|
| 196 |
+
name: str,
|
| 197 |
+
arguments: dict[str, Any] | None = None,
|
| 198 |
+
read_timeout_seconds: float | None = None,
|
| 199 |
+
progress_callback: ProgressFnT | None = None,
|
| 200 |
+
*,
|
| 201 |
+
input_responses: types.InputResponses | None = None,
|
| 202 |
+
request_state: str | None = None,
|
| 203 |
+
meta: types.RequestParamsMeta | None = None,
|
| 204 |
+
allow_input_required: Literal[False] = False,
|
| 205 |
+
) -> types.CallToolResult: ...
|
| 206 |
+
|
| 207 |
+
@overload
|
| 208 |
+
async def call_tool(
|
| 209 |
+
self,
|
| 210 |
+
name: str,
|
| 211 |
+
arguments: dict[str, Any] | None = None,
|
| 212 |
+
read_timeout_seconds: float | None = None,
|
| 213 |
+
progress_callback: ProgressFnT | None = None,
|
| 214 |
+
*,
|
| 215 |
+
input_responses: types.InputResponses | None = None,
|
| 216 |
+
request_state: str | None = None,
|
| 217 |
+
meta: types.RequestParamsMeta | None = None,
|
| 218 |
+
allow_input_required: bool,
|
| 219 |
+
) -> types.CallToolResult | types.InputRequiredResult: ...
|
| 220 |
+
|
| 221 |
+
async def call_tool(
|
| 222 |
+
self,
|
| 223 |
+
name: str,
|
| 224 |
+
arguments: dict[str, Any] | None = None,
|
| 225 |
+
read_timeout_seconds: float | None = None,
|
| 226 |
+
progress_callback: ProgressFnT | None = None,
|
| 227 |
+
*,
|
| 228 |
+
input_responses: types.InputResponses | None = None,
|
| 229 |
+
request_state: str | None = None,
|
| 230 |
+
meta: types.RequestParamsMeta | None = None,
|
| 231 |
+
allow_input_required: bool = False,
|
| 232 |
+
) -> types.CallToolResult | types.InputRequiredResult:
|
| 233 |
+
"""Executes a tool given its name and arguments.
|
| 234 |
+
|
| 235 |
+
Raises:
|
| 236 |
+
RuntimeError: If the server returns an `InputRequiredResult` and
|
| 237 |
+
``allow_input_required`` is ``False``.
|
| 238 |
+
"""
|
| 239 |
+
session = self._tool_to_session[name]
|
| 240 |
+
session_tool_name = self.tools[name].name
|
| 241 |
+
return await session.call_tool(
|
| 242 |
+
session_tool_name,
|
| 243 |
+
arguments=arguments,
|
| 244 |
+
read_timeout_seconds=read_timeout_seconds,
|
| 245 |
+
progress_callback=progress_callback,
|
| 246 |
+
input_responses=input_responses,
|
| 247 |
+
request_state=request_state,
|
| 248 |
+
meta=meta,
|
| 249 |
+
allow_input_required=allow_input_required,
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
async def disconnect_from_server(self, session: mcp.ClientSession) -> None:
|
| 253 |
+
"""Disconnects from a single MCP server."""
|
| 254 |
+
|
| 255 |
+
session_known_for_components = session in self._sessions
|
| 256 |
+
session_known_for_stack = session in self._session_exit_stacks
|
| 257 |
+
|
| 258 |
+
if not session_known_for_components and not session_known_for_stack:
|
| 259 |
+
raise MCPError(
|
| 260 |
+
code=types.INVALID_PARAMS,
|
| 261 |
+
message="Provided session is not managed or already disconnected.",
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
if session_known_for_components: # pragma: no branch
|
| 265 |
+
component_names = self._sessions.pop(session) # Pop from _sessions tracking
|
| 266 |
+
|
| 267 |
+
# Remove prompts associated with the session.
|
| 268 |
+
for name in component_names.prompts:
|
| 269 |
+
if name in self._prompts: # pragma: no branch
|
| 270 |
+
del self._prompts[name]
|
| 271 |
+
# Remove resources associated with the session.
|
| 272 |
+
for name in component_names.resources:
|
| 273 |
+
if name in self._resources: # pragma: no branch
|
| 274 |
+
del self._resources[name]
|
| 275 |
+
# Remove tools associated with the session.
|
| 276 |
+
for name in component_names.tools:
|
| 277 |
+
if name in self._tools: # pragma: no branch
|
| 278 |
+
del self._tools[name]
|
| 279 |
+
if name in self._tool_to_session: # pragma: no branch
|
| 280 |
+
del self._tool_to_session[name]
|
| 281 |
+
|
| 282 |
+
# Clean up the session's resources via its dedicated exit stack
|
| 283 |
+
if session_known_for_stack:
|
| 284 |
+
session_stack_to_close = self._session_exit_stacks.pop(session) # pragma: no cover
|
| 285 |
+
await session_stack_to_close.aclose() # pragma: no cover
|
| 286 |
+
|
| 287 |
+
async def connect_with_session(
|
| 288 |
+
self, server_info: types.Implementation, session: mcp.ClientSession
|
| 289 |
+
) -> mcp.ClientSession:
|
| 290 |
+
"""Connects to a single MCP server."""
|
| 291 |
+
await self._aggregate_components(server_info, session)
|
| 292 |
+
return session
|
| 293 |
+
|
| 294 |
+
async def connect_to_server(
|
| 295 |
+
self,
|
| 296 |
+
server_params: ServerParameters,
|
| 297 |
+
session_params: ClientSessionParameters | None = None,
|
| 298 |
+
) -> mcp.ClientSession:
|
| 299 |
+
"""Connects to a single MCP server."""
|
| 300 |
+
server_info, session = await self._establish_session(server_params, session_params or ClientSessionParameters())
|
| 301 |
+
return await self.connect_with_session(server_info, session)
|
| 302 |
+
|
| 303 |
+
async def _establish_session(
|
| 304 |
+
self,
|
| 305 |
+
server_params: ServerParameters,
|
| 306 |
+
session_params: ClientSessionParameters,
|
| 307 |
+
) -> tuple[types.Implementation, mcp.ClientSession]:
|
| 308 |
+
"""Establish a client session to an MCP server."""
|
| 309 |
+
|
| 310 |
+
session_stack = contextlib.AsyncExitStack()
|
| 311 |
+
try:
|
| 312 |
+
# Create read and write streams that facilitate io with the server.
|
| 313 |
+
if isinstance(server_params, StdioServerParameters):
|
| 314 |
+
client = mcp.stdio_client(server_params)
|
| 315 |
+
read, write = await session_stack.enter_async_context(client)
|
| 316 |
+
elif isinstance(server_params, SseServerParameters):
|
| 317 |
+
client = sse_client(
|
| 318 |
+
url=server_params.url,
|
| 319 |
+
headers=server_params.headers,
|
| 320 |
+
timeout=server_params.timeout,
|
| 321 |
+
sse_read_timeout=server_params.sse_read_timeout,
|
| 322 |
+
)
|
| 323 |
+
read, write = await session_stack.enter_async_context(client)
|
| 324 |
+
else:
|
| 325 |
+
httpx_client = create_mcp_http_client(
|
| 326 |
+
headers=server_params.headers,
|
| 327 |
+
timeout=httpx2.Timeout(
|
| 328 |
+
server_params.timeout,
|
| 329 |
+
read=server_params.sse_read_timeout,
|
| 330 |
+
),
|
| 331 |
+
)
|
| 332 |
+
await session_stack.enter_async_context(httpx_client)
|
| 333 |
+
|
| 334 |
+
client = streamable_http_client(
|
| 335 |
+
url=server_params.url,
|
| 336 |
+
http_client=httpx_client,
|
| 337 |
+
terminate_on_close=server_params.terminate_on_close,
|
| 338 |
+
)
|
| 339 |
+
read, write = await session_stack.enter_async_context(client)
|
| 340 |
+
|
| 341 |
+
session = await session_stack.enter_async_context(
|
| 342 |
+
mcp.ClientSession(
|
| 343 |
+
read,
|
| 344 |
+
write,
|
| 345 |
+
read_timeout_seconds=session_params.read_timeout_seconds,
|
| 346 |
+
sampling_callback=session_params.sampling_callback,
|
| 347 |
+
elicitation_callback=session_params.elicitation_callback,
|
| 348 |
+
list_roots_callback=session_params.list_roots_callback,
|
| 349 |
+
logging_callback=session_params.logging_callback,
|
| 350 |
+
message_handler=session_params.message_handler,
|
| 351 |
+
client_info=session_params.client_info,
|
| 352 |
+
)
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
result = await session.initialize()
|
| 356 |
+
|
| 357 |
+
# Session successfully initialized.
|
| 358 |
+
# Store its stack and register the stack with the main group stack.
|
| 359 |
+
self._session_exit_stacks[session] = session_stack
|
| 360 |
+
# session_stack itself becomes a resource managed by the
|
| 361 |
+
# main _exit_stack.
|
| 362 |
+
await self._exit_stack.enter_async_context(session_stack)
|
| 363 |
+
|
| 364 |
+
return result.server_info, session
|
| 365 |
+
except Exception: # pragma: no cover
|
| 366 |
+
# If anything during this setup fails, ensure the session-specific
|
| 367 |
+
# stack is closed.
|
| 368 |
+
await session_stack.aclose()
|
| 369 |
+
raise
|
| 370 |
+
|
| 371 |
+
async def _aggregate_components(self, server_info: types.Implementation, session: mcp.ClientSession) -> None:
|
| 372 |
+
"""Aggregates prompts, resources, and tools from a given session."""
|
| 373 |
+
|
| 374 |
+
# Create a reverse index so we can find all prompts, resources, and
|
| 375 |
+
# tools belonging to this session. Used for removing components from
|
| 376 |
+
# the session group via self.disconnect_from_server.
|
| 377 |
+
component_names = self._ComponentNames()
|
| 378 |
+
|
| 379 |
+
# Temporary components dicts. We do not want to modify the aggregate
|
| 380 |
+
# lists in case of an intermediate failure.
|
| 381 |
+
prompts_temp: dict[str, types.Prompt] = {}
|
| 382 |
+
resources_temp: dict[str, types.Resource] = {}
|
| 383 |
+
tools_temp: dict[str, types.Tool] = {}
|
| 384 |
+
tool_to_session_temp: dict[str, mcp.ClientSession] = {}
|
| 385 |
+
|
| 386 |
+
# Query the server for its prompts and aggregate to list.
|
| 387 |
+
try:
|
| 388 |
+
prompts = (await session.list_prompts()).prompts
|
| 389 |
+
for prompt in prompts:
|
| 390 |
+
name = self._component_name(prompt.name, server_info)
|
| 391 |
+
prompts_temp[name] = prompt
|
| 392 |
+
component_names.prompts.add(name)
|
| 393 |
+
except MCPError as err: # pragma: no cover
|
| 394 |
+
logging.warning(f"Could not fetch prompts: {err}")
|
| 395 |
+
|
| 396 |
+
# Query the server for its resources and aggregate to list.
|
| 397 |
+
try:
|
| 398 |
+
resources = (await session.list_resources()).resources
|
| 399 |
+
for resource in resources:
|
| 400 |
+
name = self._component_name(resource.name, server_info)
|
| 401 |
+
resources_temp[name] = resource
|
| 402 |
+
component_names.resources.add(name)
|
| 403 |
+
except MCPError as err: # pragma: no cover
|
| 404 |
+
logging.warning(f"Could not fetch resources: {err}")
|
| 405 |
+
|
| 406 |
+
# Query the server for its tools and aggregate to list.
|
| 407 |
+
try:
|
| 408 |
+
tools = (await session.list_tools()).tools
|
| 409 |
+
for tool in tools:
|
| 410 |
+
name = self._component_name(tool.name, server_info)
|
| 411 |
+
tools_temp[name] = tool
|
| 412 |
+
tool_to_session_temp[name] = session
|
| 413 |
+
component_names.tools.add(name)
|
| 414 |
+
except MCPError as err: # pragma: no cover
|
| 415 |
+
logging.warning(f"Could not fetch tools: {err}")
|
| 416 |
+
|
| 417 |
+
# Clean up exit stack for session if we couldn't retrieve anything
|
| 418 |
+
# from the server.
|
| 419 |
+
if not any((prompts_temp, resources_temp, tools_temp)):
|
| 420 |
+
del self._session_exit_stacks[session] # pragma: no cover
|
| 421 |
+
|
| 422 |
+
# Check for duplicates.
|
| 423 |
+
matching_prompts = prompts_temp.keys() & self._prompts.keys()
|
| 424 |
+
if matching_prompts:
|
| 425 |
+
raise MCPError( # pragma: no cover
|
| 426 |
+
code=types.INVALID_PARAMS,
|
| 427 |
+
message=f"{matching_prompts} already exist in group prompts.",
|
| 428 |
+
)
|
| 429 |
+
matching_resources = resources_temp.keys() & self._resources.keys()
|
| 430 |
+
if matching_resources:
|
| 431 |
+
raise MCPError( # pragma: no cover
|
| 432 |
+
code=types.INVALID_PARAMS,
|
| 433 |
+
message=f"{matching_resources} already exist in group resources.",
|
| 434 |
+
)
|
| 435 |
+
matching_tools = tools_temp.keys() & self._tools.keys()
|
| 436 |
+
if matching_tools:
|
| 437 |
+
raise MCPError(code=types.INVALID_PARAMS, message=f"{matching_tools} already exist in group tools.")
|
| 438 |
+
|
| 439 |
+
# Aggregate components.
|
| 440 |
+
self._sessions[session] = component_names
|
| 441 |
+
self._prompts.update(prompts_temp)
|
| 442 |
+
self._resources.update(resources_temp)
|
| 443 |
+
self._tools.update(tools_temp)
|
| 444 |
+
self._tool_to_session.update(tool_to_session_temp)
|
| 445 |
+
|
| 446 |
+
def _component_name(self, name: str, server_info: types.Implementation) -> str:
|
| 447 |
+
if self._component_name_hook:
|
| 448 |
+
return self._component_name_hook(name, server_info)
|
| 449 |
+
return name
|
.mcp-venv/lib/python3.13/site-packages/mcp/client/sse.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from collections.abc import Callable
|
| 3 |
+
from contextlib import asynccontextmanager
|
| 4 |
+
from typing import Any
|
| 5 |
+
from urllib.parse import parse_qs, urljoin, urlparse
|
| 6 |
+
|
| 7 |
+
import anyio
|
| 8 |
+
import httpx2
|
| 9 |
+
import mcp_types as types
|
| 10 |
+
from anyio.abc import TaskStatus
|
| 11 |
+
from httpx2 import SSEError
|
| 12 |
+
|
| 13 |
+
from mcp.shared._compat import resync_tracer
|
| 14 |
+
from mcp.shared._context_streams import create_context_streams
|
| 15 |
+
from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client
|
| 16 |
+
from mcp.shared.message import SessionMessage
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def remove_request_params(url: str) -> str:
|
| 22 |
+
return urljoin(url, urlparse(url).path)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _extract_session_id_from_endpoint(endpoint_url: str) -> str | None:
|
| 26 |
+
query_params = parse_qs(urlparse(endpoint_url).query)
|
| 27 |
+
return query_params.get("sessionId", [None])[0] or query_params.get("session_id", [None])[0]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@asynccontextmanager
|
| 31 |
+
async def sse_client(
|
| 32 |
+
url: str,
|
| 33 |
+
headers: dict[str, Any] | None = None,
|
| 34 |
+
timeout: float = 5.0,
|
| 35 |
+
sse_read_timeout: float = 300.0,
|
| 36 |
+
httpx_client_factory: McpHttpClientFactory = create_mcp_http_client,
|
| 37 |
+
auth: httpx2.Auth | None = None,
|
| 38 |
+
on_session_created: Callable[[str], None] | None = None,
|
| 39 |
+
):
|
| 40 |
+
"""Client transport for SSE.
|
| 41 |
+
|
| 42 |
+
`sse_read_timeout` determines how long (in seconds) the client will wait for a new
|
| 43 |
+
event before disconnecting. All other HTTP operations are controlled by `timeout`.
|
| 44 |
+
|
| 45 |
+
Args:
|
| 46 |
+
url: The SSE endpoint URL.
|
| 47 |
+
headers: Optional headers to include in requests.
|
| 48 |
+
timeout: HTTP timeout for regular operations (in seconds).
|
| 49 |
+
sse_read_timeout: Timeout for SSE read operations (in seconds).
|
| 50 |
+
httpx_client_factory: Factory function for creating the httpx2 client.
|
| 51 |
+
auth: Optional httpx2 authentication handler.
|
| 52 |
+
on_session_created: Optional callback invoked with the session ID when received.
|
| 53 |
+
"""
|
| 54 |
+
logger.debug(f"Connecting to SSE endpoint: {remove_request_params(url)}")
|
| 55 |
+
async with httpx_client_factory(
|
| 56 |
+
headers=headers, auth=auth, timeout=httpx2.Timeout(timeout, read=sse_read_timeout)
|
| 57 |
+
) as client:
|
| 58 |
+
async with client.sse(url) as event_source:
|
| 59 |
+
event_source.response.raise_for_status()
|
| 60 |
+
logger.debug("SSE connection established")
|
| 61 |
+
|
| 62 |
+
read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0)
|
| 63 |
+
write_stream, write_stream_reader = create_context_streams[SessionMessage](0)
|
| 64 |
+
|
| 65 |
+
async def sse_reader(task_status: TaskStatus[str] = anyio.TASK_STATUS_IGNORED):
|
| 66 |
+
try:
|
| 67 |
+
async for sse in event_source: # pragma: no branch
|
| 68 |
+
logger.debug(f"Received SSE event: {sse.event}")
|
| 69 |
+
match sse.event:
|
| 70 |
+
case "endpoint":
|
| 71 |
+
endpoint_url = urljoin(url, sse.data)
|
| 72 |
+
logger.debug(f"Received endpoint URL: {endpoint_url}")
|
| 73 |
+
|
| 74 |
+
url_parsed = urlparse(url)
|
| 75 |
+
endpoint_parsed = urlparse(endpoint_url)
|
| 76 |
+
if ( # pragma: no cover
|
| 77 |
+
url_parsed.netloc != endpoint_parsed.netloc
|
| 78 |
+
or url_parsed.scheme != endpoint_parsed.scheme
|
| 79 |
+
):
|
| 80 |
+
error_msg = ( # pragma: no cover
|
| 81 |
+
f"Endpoint origin does not match connection origin: {endpoint_url}"
|
| 82 |
+
)
|
| 83 |
+
logger.error(error_msg) # pragma: no cover
|
| 84 |
+
raise ValueError(error_msg) # pragma: no cover
|
| 85 |
+
|
| 86 |
+
if on_session_created:
|
| 87 |
+
session_id = _extract_session_id_from_endpoint(endpoint_url)
|
| 88 |
+
if session_id:
|
| 89 |
+
on_session_created(session_id)
|
| 90 |
+
|
| 91 |
+
task_status.started(endpoint_url)
|
| 92 |
+
|
| 93 |
+
case "message":
|
| 94 |
+
# Skip empty data (keep-alive pings)
|
| 95 |
+
if not sse.data:
|
| 96 |
+
continue
|
| 97 |
+
try:
|
| 98 |
+
message = types.jsonrpc_message_adapter.validate_json(sse.data, by_name=False)
|
| 99 |
+
logger.debug(f"Received server message: {message}")
|
| 100 |
+
except Exception as exc: # pragma: no cover
|
| 101 |
+
logger.exception("Error parsing server message") # pragma: no cover
|
| 102 |
+
await read_stream_writer.send(exc) # pragma: no cover
|
| 103 |
+
continue # pragma: no cover
|
| 104 |
+
|
| 105 |
+
session_message = SessionMessage(message)
|
| 106 |
+
await read_stream_writer.send(session_message)
|
| 107 |
+
case _: # pragma: no cover
|
| 108 |
+
logger.warning(f"Unknown SSE event: {sse.event}") # pragma: no cover
|
| 109 |
+
except SSEError as sse_exc: # pragma: lax no cover
|
| 110 |
+
logger.exception("Encountered SSE exception")
|
| 111 |
+
raise sse_exc
|
| 112 |
+
except Exception as exc: # pragma: lax no cover
|
| 113 |
+
logger.exception("Error in sse_reader")
|
| 114 |
+
await read_stream_writer.send(exc)
|
| 115 |
+
finally:
|
| 116 |
+
await read_stream_writer.aclose()
|
| 117 |
+
|
| 118 |
+
async def post_writer(endpoint_url: str):
|
| 119 |
+
try:
|
| 120 |
+
async with write_stream_reader, write_stream:
|
| 121 |
+
|
| 122 |
+
async def _send_message(session_message: SessionMessage) -> None:
|
| 123 |
+
logger.debug(f"Sending client message: {session_message}")
|
| 124 |
+
response = await client.post(
|
| 125 |
+
endpoint_url,
|
| 126 |
+
json=session_message.message.model_dump(
|
| 127 |
+
by_alias=True,
|
| 128 |
+
mode="json",
|
| 129 |
+
exclude_unset=True,
|
| 130 |
+
),
|
| 131 |
+
)
|
| 132 |
+
response.raise_for_status()
|
| 133 |
+
logger.debug(f"Client message sent successfully: {response.status_code}")
|
| 134 |
+
|
| 135 |
+
async for session_message in write_stream_reader:
|
| 136 |
+
sender_ctx = write_stream_reader.last_context
|
| 137 |
+
if sender_ctx is not None:
|
| 138 |
+
async with anyio.create_task_group() as tg:
|
| 139 |
+
sender_ctx.run(tg.start_soon, _send_message, session_message)
|
| 140 |
+
else:
|
| 141 |
+
await _send_message(session_message) # pragma: no cover
|
| 142 |
+
except Exception: # pragma: lax no cover
|
| 143 |
+
logger.exception("Error in post_writer")
|
| 144 |
+
|
| 145 |
+
# On Python 3.14, coverage.py reports a phantom branch arc on this
|
| 146 |
+
# line (->yield) when nested two async-with levels deep. The branch
|
| 147 |
+
# is the unreachable "did __aexit__ suppress?" arm for memory streams.
|
| 148 |
+
async with ( # pragma: no branch
|
| 149 |
+
read_stream_writer,
|
| 150 |
+
read_stream,
|
| 151 |
+
write_stream,
|
| 152 |
+
write_stream_reader,
|
| 153 |
+
anyio.create_task_group() as tg,
|
| 154 |
+
):
|
| 155 |
+
endpoint_url = await tg.start(sse_reader)
|
| 156 |
+
logger.debug(f"Starting post writer with endpoint URL: {endpoint_url}")
|
| 157 |
+
tg.start_soon(post_writer, endpoint_url)
|
| 158 |
+
|
| 159 |
+
yield read_stream, write_stream
|
| 160 |
+
tg.cancel_scope.cancel()
|
| 161 |
+
await resync_tracer()
|
.mcp-venv/lib/python3.13/site-packages/mcp/client/stdio.py
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""stdio client transport.
|
| 2 |
+
|
| 3 |
+
Runs an MCP server as a subprocess and exchanges newline-delimited JSON-RPC
|
| 4 |
+
messages with it over stdin/stdout. Two pipe tasks bridge the server's pipes
|
| 5 |
+
to the session's in-memory streams; shutdown follows the MCP spec sequence
|
| 6 |
+
(close stdin, wait, then kill the process tree) inside a cancellation shield
|
| 7 |
+
with every wait bounded, so a cancelled caller can neither leak a live server
|
| 8 |
+
process nor hang on one.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import logging
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
from collections.abc import AsyncGenerator
|
| 15 |
+
from contextlib import asynccontextmanager, suppress
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Literal, TextIO
|
| 18 |
+
|
| 19 |
+
import anyio
|
| 20 |
+
import anyio.lowlevel
|
| 21 |
+
import mcp_types as types
|
| 22 |
+
from anyio.abc import AsyncResource, Process
|
| 23 |
+
from anyio.streams.text import TextReceiveStream
|
| 24 |
+
from pydantic import BaseModel, Field
|
| 25 |
+
|
| 26 |
+
from mcp.client._transport import TransportStreams
|
| 27 |
+
from mcp.os.posix.utilities import terminate_posix_process_tree
|
| 28 |
+
from mcp.os.win32.utilities import (
|
| 29 |
+
ServerProcess,
|
| 30 |
+
close_process_job,
|
| 31 |
+
create_windows_process,
|
| 32 |
+
get_windows_executable_command,
|
| 33 |
+
terminate_windows_process_tree,
|
| 34 |
+
)
|
| 35 |
+
from mcp.shared.message import SessionMessage
|
| 36 |
+
|
| 37 |
+
logger = logging.getLogger(__name__)
|
| 38 |
+
|
| 39 |
+
# Environment variables to inherit by default
|
| 40 |
+
DEFAULT_INHERITED_ENV_VARS = (
|
| 41 |
+
[
|
| 42 |
+
"APPDATA",
|
| 43 |
+
"HOMEDRIVE",
|
| 44 |
+
"HOMEPATH",
|
| 45 |
+
"LOCALAPPDATA",
|
| 46 |
+
"PATH",
|
| 47 |
+
"PATHEXT",
|
| 48 |
+
"PROCESSOR_ARCHITECTURE",
|
| 49 |
+
"SYSTEMDRIVE",
|
| 50 |
+
"SYSTEMROOT",
|
| 51 |
+
"TEMP",
|
| 52 |
+
"USERNAME",
|
| 53 |
+
"USERPROFILE",
|
| 54 |
+
]
|
| 55 |
+
if sys.platform == "win32"
|
| 56 |
+
else ["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"]
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
# Grace period for the server to exit on its own after its stdin closes.
|
| 60 |
+
PROCESS_TERMINATION_TIMEOUT = 2.0
|
| 61 |
+
|
| 62 |
+
# Extra time after SIGTERM before SIGKILL; POSIX only (Windows kills hard).
|
| 63 |
+
FORCE_KILL_TIMEOUT = 2.0
|
| 64 |
+
|
| 65 |
+
# Time for the event loop to observe a kill; only an unkillable process runs this out.
|
| 66 |
+
_KILL_REAP_TIMEOUT = 2.0
|
| 67 |
+
|
| 68 |
+
# Time for the writer to flush accepted messages before stdin closes.
|
| 69 |
+
_WRITER_FLUSH_TIMEOUT = 0.5
|
| 70 |
+
|
| 71 |
+
# How often to poll returncode while waiting for the process to die.
|
| 72 |
+
_EXIT_POLL_INTERVAL = 0.01
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def get_default_environment() -> dict[str, str]:
|
| 76 |
+
"""Returns only the environment variables that are safe to inherit."""
|
| 77 |
+
env: dict[str, str] = {}
|
| 78 |
+
|
| 79 |
+
for key in DEFAULT_INHERITED_ENV_VARS:
|
| 80 |
+
value = os.environ.get(key)
|
| 81 |
+
if value is None: # pragma: lax no cover
|
| 82 |
+
continue
|
| 83 |
+
|
| 84 |
+
if value.startswith("()"): # pragma: no cover
|
| 85 |
+
# Skip functions, which are a security risk
|
| 86 |
+
continue # pragma: no cover
|
| 87 |
+
|
| 88 |
+
env[key] = value
|
| 89 |
+
|
| 90 |
+
return env
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class StdioServerParameters(BaseModel):
|
| 94 |
+
command: str
|
| 95 |
+
"""The executable to run to start the server."""
|
| 96 |
+
|
| 97 |
+
args: list[str] = Field(default_factory=list)
|
| 98 |
+
"""Command line arguments to pass to the executable."""
|
| 99 |
+
|
| 100 |
+
env: dict[str, str] | None = None
|
| 101 |
+
"""Extra environment variables, merged over get_default_environment()."""
|
| 102 |
+
|
| 103 |
+
cwd: str | Path | None = None
|
| 104 |
+
"""The working directory to use when spawning the process."""
|
| 105 |
+
|
| 106 |
+
encoding: str = "utf-8"
|
| 107 |
+
"""Text encoding for messages to and from the server."""
|
| 108 |
+
|
| 109 |
+
encoding_error_handler: Literal["strict", "ignore", "replace"] = "strict"
|
| 110 |
+
"""Encoding error handler; see https://docs.python.org/3/library/codecs.html#error-handlers."""
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
@asynccontextmanager
|
| 114 |
+
async def stdio_client(
|
| 115 |
+
server: StdioServerParameters, errlog: TextIO = sys.stderr
|
| 116 |
+
) -> AsyncGenerator[TransportStreams, None]:
|
| 117 |
+
"""Spawns an MCP server subprocess and connects to it over stdin/stdout.
|
| 118 |
+
|
| 119 |
+
Raises:
|
| 120 |
+
OSError: If the server process cannot be spawned.
|
| 121 |
+
ValueError: If the spawn parameters are invalid (embedded NUL bytes).
|
| 122 |
+
"""
|
| 123 |
+
command = _get_executable_command(server.command)
|
| 124 |
+
|
| 125 |
+
process = await _create_platform_compatible_process(
|
| 126 |
+
command=command,
|
| 127 |
+
args=server.args,
|
| 128 |
+
env=get_default_environment() | (server.env or {}),
|
| 129 |
+
errlog=errlog,
|
| 130 |
+
cwd=server.cwd,
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
# The spawn succeeded; no awaits until the task group is entered, or a
|
| 134 |
+
# cancellation delivered in the gap would leak the live process.
|
| 135 |
+
read_stream_writer, read_stream = anyio.create_memory_object_stream[SessionMessage | Exception](0)
|
| 136 |
+
write_stream, write_stream_reader = anyio.create_memory_object_stream[SessionMessage](0)
|
| 137 |
+
|
| 138 |
+
shutting_down = False
|
| 139 |
+
writer_done = anyio.Event()
|
| 140 |
+
|
| 141 |
+
async def stdout_reader() -> None:
|
| 142 |
+
assert process.stdout, "Opened process is missing stdout"
|
| 143 |
+
|
| 144 |
+
stdout = TextReceiveStream(process.stdout, encoding=server.encoding, errors=server.encoding_error_handler)
|
| 145 |
+
try:
|
| 146 |
+
async with read_stream_writer:
|
| 147 |
+
try:
|
| 148 |
+
# One line at a time; no read-ahead while a delivery is blocked.
|
| 149 |
+
buffer = ""
|
| 150 |
+
async for chunk in stdout:
|
| 151 |
+
lines = (buffer + chunk).split("\n")
|
| 152 |
+
buffer = lines.pop()
|
| 153 |
+
for line in lines:
|
| 154 |
+
try:
|
| 155 |
+
await read_stream_writer.send(_parse_line(line))
|
| 156 |
+
except (anyio.ClosedResourceError, anyio.BrokenResourceError):
|
| 157 |
+
return # the session is gone; only the drain below remains
|
| 158 |
+
finally:
|
| 159 |
+
await _drain_stdout(process)
|
| 160 |
+
except anyio.ClosedResourceError:
|
| 161 |
+
pass # our own shutdown closed the stdout stream under the read
|
| 162 |
+
except (anyio.BrokenResourceError, ConnectionError):
|
| 163 |
+
# Teardown noise during shutdown, a real failure otherwise; either way
|
| 164 |
+
# the session sees clean closure when the read stream closes.
|
| 165 |
+
if not shutting_down:
|
| 166 |
+
logger.exception("Reading from the MCP server's stdout failed mid-session")
|
| 167 |
+
|
| 168 |
+
async def stdin_writer() -> None:
|
| 169 |
+
assert process.stdin, "Opened process is missing stdin"
|
| 170 |
+
|
| 171 |
+
try:
|
| 172 |
+
async with write_stream_reader:
|
| 173 |
+
async for session_message in write_stream_reader:
|
| 174 |
+
json = session_message.message.model_dump_json(by_alias=True, exclude_unset=True)
|
| 175 |
+
data = (json + "\n").encode(encoding=server.encoding, errors=server.encoding_error_handler)
|
| 176 |
+
await process.stdin.send(data)
|
| 177 |
+
except (anyio.ClosedResourceError, anyio.BrokenResourceError, OSError):
|
| 178 |
+
# The server may still be alive: close the read stream so the session
|
| 179 |
+
# sees the connection end instead of a request hanging forever.
|
| 180 |
+
await read_stream_writer.aclose()
|
| 181 |
+
finally:
|
| 182 |
+
writer_done.set()
|
| 183 |
+
|
| 184 |
+
async def shutdown() -> None:
|
| 185 |
+
"""Winds the transport down: stop traffic, flush, stop the server, release the streams."""
|
| 186 |
+
# Unblock the reader into its drain: a server stuck writing stdout cannot
|
| 187 |
+
# read its stdin, so draining is what lets the flush below complete.
|
| 188 |
+
read_stream.close()
|
| 189 |
+
# Bounded window for the writer to flush already-accepted messages.
|
| 190 |
+
write_stream.close()
|
| 191 |
+
with anyio.move_on_after(_WRITER_FLUSH_TIMEOUT) as flush_scope:
|
| 192 |
+
await writer_done.wait()
|
| 193 |
+
if flush_scope.cancelled_caught:
|
| 194 |
+
await anyio.lowlevel.cancel_shielded_checkpoint() # resync coverage on 3.11 (gh-106749)
|
| 195 |
+
await _stop_server_process(process)
|
| 196 |
+
await _aclose_all(read_stream, write_stream, read_stream_writer, write_stream_reader)
|
| 197 |
+
# One pass so unblocked tasks exit via their except paths before the cancel.
|
| 198 |
+
await anyio.lowlevel.checkpoint()
|
| 199 |
+
|
| 200 |
+
async with anyio.create_task_group() as tg:
|
| 201 |
+
tg.start_soon(stdout_reader)
|
| 202 |
+
tg.start_soon(stdin_writer)
|
| 203 |
+
try:
|
| 204 |
+
yield read_stream, write_stream
|
| 205 |
+
finally:
|
| 206 |
+
shutting_down = True
|
| 207 |
+
# Shutdown must finish even under caller cancellation, or the server
|
| 208 |
+
# process would leak; every wait inside is bounded. (Native
|
| 209 |
+
# task.cancel() and the fallback's worker threads can still defeat it.)
|
| 210 |
+
with anyio.CancelScope(shield=True):
|
| 211 |
+
await shutdown()
|
| 212 |
+
# Unstick pipe tasks a kill survivor's open pipe end could still block.
|
| 213 |
+
tg.cancel_scope.cancel()
|
| 214 |
+
# The cancel lands via throw(); one yield resyncs 3.11 coverage (gh-106749).
|
| 215 |
+
await anyio.lowlevel.cancel_shielded_checkpoint()
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _parse_line(line: str) -> SessionMessage | Exception:
|
| 219 |
+
"""Parses one stdout line, returning parse errors as values for the session to surface."""
|
| 220 |
+
try:
|
| 221 |
+
message = types.jsonrpc_message_adapter.validate_json(line, by_name=False)
|
| 222 |
+
except ValueError as exc:
|
| 223 |
+
logger.exception("Failed to parse JSONRPC message from server")
|
| 224 |
+
return exc
|
| 225 |
+
return SessionMessage(message)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
async def _drain_stdout(process: ServerProcess) -> None:
|
| 229 |
+
"""Consumes and discards the server's remaining stdout.
|
| 230 |
+
|
| 231 |
+
Keeps a server flushing buffered output from blocking on a full pipe and
|
| 232 |
+
missing its chance to exit; shielded, raw bytes, ends when shutdown closes
|
| 233 |
+
the pipe.
|
| 234 |
+
"""
|
| 235 |
+
assert process.stdout
|
| 236 |
+
with anyio.CancelScope(shield=True):
|
| 237 |
+
with suppress(
|
| 238 |
+
anyio.EndOfStream,
|
| 239 |
+
anyio.ClosedResourceError,
|
| 240 |
+
anyio.BrokenResourceError,
|
| 241 |
+
ConnectionError,
|
| 242 |
+
OSError,
|
| 243 |
+
):
|
| 244 |
+
while True:
|
| 245 |
+
await process.stdout.receive()
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
async def _stop_server_process(process: ServerProcess) -> None:
|
| 249 |
+
"""Closes stdin, waits out the grace period, then kills the whole tree.
|
| 250 |
+
|
| 251 |
+
The escalation order is spec text; timeouts and tree-wide scope are SDK policy:
|
| 252 |
+
https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#shutdown
|
| 253 |
+
"""
|
| 254 |
+
assert process.stdin and process.stdout, "server process is spawned with pipes"
|
| 255 |
+
|
| 256 |
+
await _close_pipe(process.stdin)
|
| 257 |
+
if not await _wait_for_process_exit(process, PROCESS_TERMINATION_TIMEOUT):
|
| 258 |
+
await _terminate_process_tree(process)
|
| 259 |
+
# Until the event loop observes the death, the transport cannot close.
|
| 260 |
+
if not await _wait_for_process_exit(process, _KILL_REAP_TIMEOUT):
|
| 261 |
+
logger.warning("MCP server process %d is still alive after the kill escalation; abandoning it", process.pid)
|
| 262 |
+
|
| 263 |
+
# Reaps surviving Windows job members now, not at GC; no-op on POSIX.
|
| 264 |
+
close_process_job(process)
|
| 265 |
+
# A kill survivor can hold the stdout pipe open; poison the reader anyway.
|
| 266 |
+
await _close_pipe(process.stdout)
|
| 267 |
+
_close_subprocess_transport(process)
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
async def _close_pipe(stream: AsyncResource) -> None:
|
| 271 |
+
"""Closes a pipe stream, tolerating one already closed, broken, or contended."""
|
| 272 |
+
with suppress(OSError, anyio.BrokenResourceError, anyio.ClosedResourceError):
|
| 273 |
+
await stream.aclose()
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
async def _wait_for_process_exit(process: ServerProcess, timeout: float) -> bool:
|
| 277 |
+
"""Returns whether the process died within the timeout, by polling returncode.
|
| 278 |
+
|
| 279 |
+
Not process.wait(): on asyncio 3.11+ it also waits for pipe EOF, and a
|
| 280 |
+
child that inherited the pipes makes an exited server look hung.
|
| 281 |
+
"""
|
| 282 |
+
deadline = anyio.current_time() + timeout
|
| 283 |
+
while process.returncode is None:
|
| 284 |
+
if anyio.current_time() >= deadline:
|
| 285 |
+
return False
|
| 286 |
+
await anyio.sleep(_EXIT_POLL_INTERVAL)
|
| 287 |
+
return True
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
async def _terminate_process_tree(process: ServerProcess) -> None:
|
| 291 |
+
"""Kills the process and all its descendants.
|
| 292 |
+
|
| 293 |
+
POSIX: SIGTERM to the process group, SIGKILL after FORCE_KILL_TIMEOUT.
|
| 294 |
+
Windows: immediate Job Object termination (already a hard kill).
|
| 295 |
+
"""
|
| 296 |
+
if sys.platform == "win32": # pragma: no cover
|
| 297 |
+
await terminate_windows_process_tree(process)
|
| 298 |
+
else: # pragma: lax no cover
|
| 299 |
+
# The Windows-only FallbackProcess never reaches the POSIX path.
|
| 300 |
+
assert isinstance(process, Process)
|
| 301 |
+
await terminate_posix_process_tree(process, FORCE_KILL_TIMEOUT)
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def _close_subprocess_transport(process: ServerProcess) -> None:
|
| 305 |
+
"""Closes the asyncio subprocess transport, if there is one.
|
| 306 |
+
|
| 307 |
+
The transport otherwise stays open (and warns at GC) while a surviving
|
| 308 |
+
descendant holds a pipe end; nothing public exposes it, hence the attribute
|
| 309 |
+
walk. No-op on trio and the Windows fallback.
|
| 310 |
+
"""
|
| 311 |
+
transport = getattr(getattr(process, "_process", None), "_transport", None)
|
| 312 |
+
# Duck-typed: uvloop's UVProcessTransport is not an asyncio.SubprocessTransport.
|
| 313 |
+
close = getattr(transport, "close", None)
|
| 314 |
+
if callable(close):
|
| 315 |
+
# close() on <=3.12 can raise PermissionError re-killing a setuid child.
|
| 316 |
+
with suppress(PermissionError):
|
| 317 |
+
close()
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def _get_executable_command(command: str) -> str:
|
| 321 |
+
"""Normalizes the command for the current platform."""
|
| 322 |
+
if sys.platform == "win32": # pragma: no cover
|
| 323 |
+
return get_windows_executable_command(command)
|
| 324 |
+
else: # pragma: lax no cover
|
| 325 |
+
return command
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
async def _create_platform_compatible_process(
|
| 329 |
+
command: str,
|
| 330 |
+
args: list[str],
|
| 331 |
+
env: dict[str, str] | None = None,
|
| 332 |
+
errlog: TextIO = sys.stderr,
|
| 333 |
+
cwd: Path | str | None = None,
|
| 334 |
+
) -> ServerProcess:
|
| 335 |
+
"""Spawns the server in its own kill scope.
|
| 336 |
+
|
| 337 |
+
A new session/process group on POSIX, a Job Object on Windows.
|
| 338 |
+
"""
|
| 339 |
+
if sys.platform == "win32": # pragma: no cover
|
| 340 |
+
return await create_windows_process(command, args, env, errlog, cwd)
|
| 341 |
+
else: # pragma: lax no cover
|
| 342 |
+
return await anyio.open_process(
|
| 343 |
+
[command, *args],
|
| 344 |
+
env=env,
|
| 345 |
+
stderr=errlog,
|
| 346 |
+
cwd=cwd,
|
| 347 |
+
start_new_session=True,
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
async def _aclose_all(*streams: AsyncResource) -> None:
|
| 352 |
+
"""Closes every given stream."""
|
| 353 |
+
for stream in streams:
|
| 354 |
+
await stream.aclose()
|
.mcp-venv/lib/python3.13/site-packages/mcp/client/streamable_http.py
ADDED
|
@@ -0,0 +1,710 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Implements StreamableHTTP transport for MCP clients."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations as _annotations
|
| 4 |
+
|
| 5 |
+
import contextlib
|
| 6 |
+
import logging
|
| 7 |
+
from collections.abc import AsyncGenerator, Awaitable, Callable
|
| 8 |
+
from contextlib import asynccontextmanager
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
|
| 11 |
+
import anyio
|
| 12 |
+
import httpx2
|
| 13 |
+
from anyio.abc import TaskGroup
|
| 14 |
+
from httpx2 import EventSource, ServerSentEvent
|
| 15 |
+
from mcp_types import (
|
| 16 |
+
CONNECTION_CLOSED,
|
| 17 |
+
INTERNAL_ERROR,
|
| 18 |
+
INVALID_REQUEST,
|
| 19 |
+
METHOD_NOT_FOUND,
|
| 20 |
+
PARSE_ERROR,
|
| 21 |
+
ErrorData,
|
| 22 |
+
JSONRPCError,
|
| 23 |
+
JSONRPCMessage,
|
| 24 |
+
JSONRPCNotification,
|
| 25 |
+
JSONRPCRequest,
|
| 26 |
+
JSONRPCResponse,
|
| 27 |
+
RequestId,
|
| 28 |
+
jsonrpc_message_adapter,
|
| 29 |
+
)
|
| 30 |
+
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
|
| 31 |
+
from pydantic import ValidationError
|
| 32 |
+
|
| 33 |
+
from mcp.client._transport import TransportStreams
|
| 34 |
+
from mcp.shared._compat import resync_tracer
|
| 35 |
+
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
|
| 36 |
+
from mcp.shared._httpx_utils import create_mcp_http_client
|
| 37 |
+
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
|
| 38 |
+
from mcp.shared.jsonrpc_dispatcher import cancelled_request_id_from_params
|
| 39 |
+
from mcp.shared.message import ClientMessageMetadata, SessionMessage
|
| 40 |
+
|
| 41 |
+
logger = logging.getLogger(__name__)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# TODO(Marcelo): Put the TransportStreams in a module under shared, so we can import here.
|
| 45 |
+
SessionMessageOrError = SessionMessage | Exception
|
| 46 |
+
StreamWriter = ContextSendStream[SessionMessageOrError]
|
| 47 |
+
StreamReader = ContextReceiveStream[SessionMessage]
|
| 48 |
+
|
| 49 |
+
MCP_SESSION_ID = "mcp-session-id"
|
| 50 |
+
LAST_EVENT_ID = "last-event-id"
|
| 51 |
+
|
| 52 |
+
# Reconnection defaults
|
| 53 |
+
DEFAULT_RECONNECTION_DELAY_MS = 1000 # 1 second fallback when server doesn't provide retry
|
| 54 |
+
MAX_RECONNECTION_ATTEMPTS = 2 # Max retry attempts before giving up
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class StreamableHTTPError(Exception):
|
| 58 |
+
"""Base exception for StreamableHTTP transport errors."""
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class ResumptionError(StreamableHTTPError):
|
| 62 |
+
"""Raised when resumption request is invalid."""
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@dataclass
|
| 66 |
+
class RequestContext:
|
| 67 |
+
"""Context for a request operation."""
|
| 68 |
+
|
| 69 |
+
client: httpx2.AsyncClient
|
| 70 |
+
session_id: str | None
|
| 71 |
+
session_message: SessionMessage
|
| 72 |
+
metadata: ClientMessageMetadata | None
|
| 73 |
+
read_stream_writer: StreamWriter
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@dataclass(slots=True)
|
| 77 |
+
class _InFlightPost:
|
| 78 |
+
"""A request POST in flight: its abort scope and the era it was sent under.
|
| 79 |
+
|
| 80 |
+
`modern` is the negotiated-version cache as of this request's dequeue, so a
|
| 81 |
+
later cancel frame is interpreted under the era the request actually ran
|
| 82 |
+
with, not whatever the cache says by then.
|
| 83 |
+
"""
|
| 84 |
+
|
| 85 |
+
scope: anyio.CancelScope
|
| 86 |
+
modern: bool
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class StreamableHTTPTransport:
|
| 90 |
+
"""StreamableHTTP client transport implementation."""
|
| 91 |
+
|
| 92 |
+
def __init__(self, url: str) -> None:
|
| 93 |
+
"""Initialize the StreamableHTTP transport.
|
| 94 |
+
|
| 95 |
+
Args:
|
| 96 |
+
url: The endpoint URL.
|
| 97 |
+
"""
|
| 98 |
+
self.url = url
|
| 99 |
+
self.session_id: str | None = None
|
| 100 |
+
# Captured from each stamped message's metadata, synchronously in the
|
| 101 |
+
# post_writer loop so the cache always reflects wire order (a POST task's
|
| 102 |
+
# scheduling is arbitrary). Reused on outbound HTTP that carries no
|
| 103 |
+
# per-message header (transport-internal GET/DELETE, and dispatcher-written
|
| 104 |
+
# response/error POSTs that bypass the session's stamp), and consulted by
|
| 105 |
+
# `_consume_modern_cancellation`. Cleared when an `initialize` message is
|
| 106 |
+
# dequeued so a probe-stamped value cannot leak onto the handshake.
|
| 107 |
+
self._protocol_version_header: str | None = None
|
| 108 |
+
# Every request's POST runs inside one of these so an outbound
|
| 109 |
+
# `notifications/cancelled` at 2026 can abort it; see
|
| 110 |
+
# `_consume_modern_cancellation`. Keys are verbatim-typed ("1" is not 1).
|
| 111 |
+
self._in_flight_posts: dict[RequestId, _InFlightPost] = {}
|
| 112 |
+
|
| 113 |
+
def _prepare_headers(self) -> dict[str, str]:
|
| 114 |
+
"""Build MCP-specific request headers for any outbound HTTP request.
|
| 115 |
+
|
| 116 |
+
These are merged with the ``httpx2.AsyncClient`` defaults (these take
|
| 117 |
+
precedence). The cached ``MCP-Protocol-Version`` is included whenever
|
| 118 |
+
present so messages that don't pass through the session's stamp —
|
| 119 |
+
response/error POSTs, legacy cancel frames, transport-internal
|
| 120 |
+
GET/DELETE — still carry the negotiated version. Per-message headers
|
| 121 |
+
are layered on top by the caller.
|
| 122 |
+
"""
|
| 123 |
+
headers: dict[str, str] = {
|
| 124 |
+
"accept": "application/json, text/event-stream",
|
| 125 |
+
"content-type": "application/json",
|
| 126 |
+
}
|
| 127 |
+
if self.session_id:
|
| 128 |
+
headers[MCP_SESSION_ID] = self.session_id
|
| 129 |
+
if self._protocol_version_header:
|
| 130 |
+
headers[MCP_PROTOCOL_VERSION_HEADER] = self._protocol_version_header
|
| 131 |
+
return headers
|
| 132 |
+
|
| 133 |
+
def _is_initialization_request(self, message: JSONRPCMessage) -> bool:
|
| 134 |
+
"""Check if the message is an initialization request."""
|
| 135 |
+
return isinstance(message, JSONRPCRequest) and message.method == "initialize"
|
| 136 |
+
|
| 137 |
+
def _is_initialized_notification(self, message: JSONRPCMessage) -> bool:
|
| 138 |
+
"""Check if the message is an initialized notification."""
|
| 139 |
+
return isinstance(message, JSONRPCNotification) and message.method == "notifications/initialized"
|
| 140 |
+
|
| 141 |
+
def _maybe_extract_session_id_from_response(self, response: httpx2.Response) -> None:
|
| 142 |
+
"""Extract and store session ID from response headers."""
|
| 143 |
+
new_session_id = response.headers.get(MCP_SESSION_ID)
|
| 144 |
+
if new_session_id:
|
| 145 |
+
self.session_id = new_session_id
|
| 146 |
+
logger.info(f"Received session ID: {self.session_id}")
|
| 147 |
+
|
| 148 |
+
async def _handle_sse_event(
|
| 149 |
+
self,
|
| 150 |
+
sse: ServerSentEvent,
|
| 151 |
+
read_stream_writer: StreamWriter,
|
| 152 |
+
original_request_id: RequestId | None = None,
|
| 153 |
+
resumption_callback: Callable[[str], Awaitable[None]] | None = None,
|
| 154 |
+
) -> bool:
|
| 155 |
+
"""Handle an SSE event, returning True if the response is complete."""
|
| 156 |
+
if sse.event == "message":
|
| 157 |
+
# Handle priming events (empty data with ID) for resumability
|
| 158 |
+
if not sse.data:
|
| 159 |
+
# Call resumption callback for priming events that have an ID
|
| 160 |
+
if sse.id and resumption_callback:
|
| 161 |
+
await resumption_callback(sse.id)
|
| 162 |
+
return False
|
| 163 |
+
try:
|
| 164 |
+
message = jsonrpc_message_adapter.validate_json(sse.data, by_name=False)
|
| 165 |
+
logger.debug(f"SSE message: {message}")
|
| 166 |
+
|
| 167 |
+
# If this is a response and we have original_request_id, replace it
|
| 168 |
+
if original_request_id is not None and isinstance(message, JSONRPCResponse | JSONRPCError):
|
| 169 |
+
message.id = original_request_id
|
| 170 |
+
|
| 171 |
+
session_message = SessionMessage(message)
|
| 172 |
+
await read_stream_writer.send(session_message)
|
| 173 |
+
|
| 174 |
+
# Call resumption token callback if we have an ID
|
| 175 |
+
if sse.id and resumption_callback:
|
| 176 |
+
await resumption_callback(sse.id)
|
| 177 |
+
|
| 178 |
+
# If this is a response or error return True indicating completion
|
| 179 |
+
# Otherwise, return False to continue listening
|
| 180 |
+
return isinstance(message, JSONRPCResponse | JSONRPCError)
|
| 181 |
+
|
| 182 |
+
# Forwarding to a closed read stream lands here when the caller cancels mid-SSE
|
| 183 |
+
# (BrokenResourceError, not a parse failure); coverage is timing-dependent in the
|
| 184 |
+
# streaming story's modern HTTP cancellation leg.
|
| 185 |
+
except Exception as exc: # pragma: lax no cover
|
| 186 |
+
logger.exception("Error parsing SSE message")
|
| 187 |
+
if original_request_id is not None:
|
| 188 |
+
error_data = ErrorData(code=PARSE_ERROR, message=f"Failed to parse SSE message: {exc}")
|
| 189 |
+
error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=original_request_id, error=error_data))
|
| 190 |
+
await read_stream_writer.send(error_msg)
|
| 191 |
+
return True
|
| 192 |
+
await read_stream_writer.send(exc)
|
| 193 |
+
return False
|
| 194 |
+
else: # pragma: no cover
|
| 195 |
+
logger.warning(f"Unknown SSE event: {sse.event}")
|
| 196 |
+
return False
|
| 197 |
+
|
| 198 |
+
async def handle_get_stream(self, client: httpx2.AsyncClient, read_stream_writer: StreamWriter) -> None:
|
| 199 |
+
"""Handle GET stream for server-initiated messages with auto-reconnect."""
|
| 200 |
+
last_event_id: str | None = None
|
| 201 |
+
retry_interval_ms: int | None = None
|
| 202 |
+
attempt: int = 0
|
| 203 |
+
|
| 204 |
+
while attempt < MAX_RECONNECTION_ATTEMPTS: # pragma: no branch
|
| 205 |
+
try:
|
| 206 |
+
if not self.session_id:
|
| 207 |
+
return
|
| 208 |
+
|
| 209 |
+
headers = self._prepare_headers()
|
| 210 |
+
if last_event_id:
|
| 211 |
+
headers[LAST_EVENT_ID] = last_event_id
|
| 212 |
+
|
| 213 |
+
async with client.sse(self.url, headers=headers) as event_source:
|
| 214 |
+
event_source.response.raise_for_status()
|
| 215 |
+
logger.debug("GET SSE connection established")
|
| 216 |
+
|
| 217 |
+
async for sse in event_source:
|
| 218 |
+
# Track last event ID for reconnection
|
| 219 |
+
if sse.id:
|
| 220 |
+
last_event_id = sse.id
|
| 221 |
+
# Track retry interval from server
|
| 222 |
+
if sse.retry is not None:
|
| 223 |
+
retry_interval_ms = sse.retry
|
| 224 |
+
|
| 225 |
+
await self._handle_sse_event(sse, read_stream_writer)
|
| 226 |
+
|
| 227 |
+
# Stream ended normally (server closed) - reset attempt counter
|
| 228 |
+
attempt = 0
|
| 229 |
+
|
| 230 |
+
except Exception:
|
| 231 |
+
logger.debug("GET stream error", exc_info=True)
|
| 232 |
+
attempt += 1
|
| 233 |
+
|
| 234 |
+
if attempt >= MAX_RECONNECTION_ATTEMPTS: # pragma: no cover
|
| 235 |
+
logger.debug(f"GET stream max reconnection attempts ({MAX_RECONNECTION_ATTEMPTS}) exceeded")
|
| 236 |
+
return
|
| 237 |
+
|
| 238 |
+
# Wait before reconnecting
|
| 239 |
+
delay_ms = retry_interval_ms if retry_interval_ms is not None else DEFAULT_RECONNECTION_DELAY_MS
|
| 240 |
+
logger.info(f"GET stream disconnected, reconnecting in {delay_ms}ms...")
|
| 241 |
+
await anyio.sleep(delay_ms / 1000.0)
|
| 242 |
+
|
| 243 |
+
async def _handle_resumption_request(self, ctx: RequestContext) -> None:
|
| 244 |
+
"""Handle a resumption request using GET with SSE."""
|
| 245 |
+
headers = self._prepare_headers()
|
| 246 |
+
if ctx.metadata and ctx.metadata.resumption_token:
|
| 247 |
+
headers[LAST_EVENT_ID] = ctx.metadata.resumption_token
|
| 248 |
+
else:
|
| 249 |
+
raise ResumptionError("Resumption request requires a resumption token") # pragma: no cover
|
| 250 |
+
|
| 251 |
+
# Extract original request ID to map responses
|
| 252 |
+
original_request_id = None
|
| 253 |
+
if isinstance(ctx.session_message.message, JSONRPCRequest): # pragma: no branch
|
| 254 |
+
original_request_id = ctx.session_message.message.id
|
| 255 |
+
|
| 256 |
+
async with ctx.client.sse(self.url, headers=headers) as event_source:
|
| 257 |
+
event_source.response.raise_for_status()
|
| 258 |
+
logger.debug("Resumption GET SSE connection established")
|
| 259 |
+
|
| 260 |
+
async for sse in event_source: # pragma: no branch
|
| 261 |
+
is_complete = await self._handle_sse_event(
|
| 262 |
+
sse,
|
| 263 |
+
ctx.read_stream_writer,
|
| 264 |
+
original_request_id,
|
| 265 |
+
ctx.metadata.on_resumption_token_update if ctx.metadata else None,
|
| 266 |
+
)
|
| 267 |
+
if is_complete:
|
| 268 |
+
await event_source.response.aclose()
|
| 269 |
+
break
|
| 270 |
+
|
| 271 |
+
def _consume_modern_cancellation(self, session_message: SessionMessage) -> bool:
|
| 272 |
+
"""Translate an outbound `notifications/cancelled` at 2026; True means "do not POST".
|
| 273 |
+
|
| 274 |
+
The 2026 wire defines no client-to-server notifications over streamable
|
| 275 |
+
HTTP: closing a request's response stream IS its cancellation signal.
|
| 276 |
+
The dispatcher still emits the courtesy frame as its abandon signal
|
| 277 |
+
(every outbound cancel names one of our own request ids - the spec
|
| 278 |
+
forbids cancelling a request the sender did not issue), so this
|
| 279 |
+
transport translates it: when the named request's POST is in flight,
|
| 280 |
+
that POST's own recorded era decides - abort-and-swallow at 2026, POST
|
| 281 |
+
the frame below it (where the frame is the signal and a disconnect
|
| 282 |
+
explicitly is not). With no POST to consult, the cached negotiated
|
| 283 |
+
version decides; at 2026 the frame is swallowed even unmatched, so a
|
| 284 |
+
late cancel racing the response cannot leak onto the wire.
|
| 285 |
+
"""
|
| 286 |
+
message = session_message.message
|
| 287 |
+
if not (isinstance(message, JSONRPCNotification) and message.method == "notifications/cancelled"):
|
| 288 |
+
return False
|
| 289 |
+
request_id = cancelled_request_id_from_params(message.params)
|
| 290 |
+
post = self._in_flight_posts.get(request_id) if request_id is not None else None
|
| 291 |
+
if post is not None:
|
| 292 |
+
if not post.modern:
|
| 293 |
+
return False
|
| 294 |
+
logger.debug("aborting in-flight POST for cancelled request %r", request_id)
|
| 295 |
+
post.scope.cancel()
|
| 296 |
+
return True
|
| 297 |
+
return self._protocol_version_header in MODERN_PROTOCOL_VERSIONS
|
| 298 |
+
|
| 299 |
+
async def _run_request_post(
|
| 300 |
+
self,
|
| 301 |
+
post_fn: Callable[[], Awaitable[None]],
|
| 302 |
+
post: _InFlightPost,
|
| 303 |
+
request_id: RequestId,
|
| 304 |
+
) -> None:
|
| 305 |
+
"""Run one request's POST inside its abort scope (see `_consume_modern_cancellation`)."""
|
| 306 |
+
try:
|
| 307 |
+
with post.scope:
|
| 308 |
+
await post_fn()
|
| 309 |
+
finally:
|
| 310 |
+
# Identity-guarded: a reused id may already have a successor
|
| 311 |
+
# registered while this task unwinds - popping by key alone would
|
| 312 |
+
# evict the live entry and leave the new POST unabortable.
|
| 313 |
+
if self._in_flight_posts.get(request_id) is post:
|
| 314 |
+
del self._in_flight_posts[request_id]
|
| 315 |
+
|
| 316 |
+
async def _handle_post_request(self, ctx: RequestContext) -> None:
|
| 317 |
+
"""Handle a POST request with response processing."""
|
| 318 |
+
message = ctx.session_message.message
|
| 319 |
+
headers = self._prepare_headers()
|
| 320 |
+
if ctx.metadata is not None and ctx.metadata.headers is not None:
|
| 321 |
+
headers.update(ctx.metadata.headers)
|
| 322 |
+
|
| 323 |
+
async with ctx.client.stream(
|
| 324 |
+
"POST",
|
| 325 |
+
self.url,
|
| 326 |
+
json=message.model_dump(by_alias=True, mode="json", exclude_unset=True),
|
| 327 |
+
headers=headers,
|
| 328 |
+
) as response:
|
| 329 |
+
if response.status_code == 202:
|
| 330 |
+
logger.debug("Received 202 Accepted")
|
| 331 |
+
if isinstance(message, JSONRPCRequest):
|
| 332 |
+
# A request's response arrives on this POST's body; 202 says
|
| 333 |
+
# none will follow. Resolve rather than park the caller forever.
|
| 334 |
+
await self._resolve_abandoned_request(
|
| 335 |
+
ctx.read_stream_writer,
|
| 336 |
+
message.id,
|
| 337 |
+
"server answered a request with 202 Accepted",
|
| 338 |
+
code=INVALID_REQUEST,
|
| 339 |
+
)
|
| 340 |
+
return
|
| 341 |
+
|
| 342 |
+
if response.status_code >= 400:
|
| 343 |
+
if isinstance(message, JSONRPCRequest):
|
| 344 |
+
# A spec-correct server may return the JSON-RPC error in the
|
| 345 |
+
# body at a non-2xx status (e.g. 400 for INVALID_PARAMS, 404
|
| 346 |
+
# for METHOD_NOT_FOUND). Surface that error rather than the
|
| 347 |
+
# status-derived stand-in below.
|
| 348 |
+
if response.headers.get("content-type", "").lower().startswith("application/json"):
|
| 349 |
+
try:
|
| 350 |
+
body = await response.aread()
|
| 351 |
+
parsed = jsonrpc_message_adapter.validate_json(body, by_name=False)
|
| 352 |
+
if isinstance(parsed, JSONRPCError):
|
| 353 |
+
# The server may have set `id: null` (request rejected before its
|
| 354 |
+
# id was parsed); use this request's id so correlation works.
|
| 355 |
+
reply = JSONRPCError(jsonrpc="2.0", id=message.id, error=parsed.error)
|
| 356 |
+
await ctx.read_stream_writer.send(SessionMessage(reply))
|
| 357 |
+
return
|
| 358 |
+
except (httpx2.StreamError, ValidationError):
|
| 359 |
+
pass
|
| 360 |
+
logger.debug("Non-2xx body was not a JSON-RPC error; using fallback")
|
| 361 |
+
if response.status_code == 404:
|
| 362 |
+
if self.session_id is None:
|
| 363 |
+
# No session yet → 404 is the HTTP-level spelling of
|
| 364 |
+
# METHOD_NOT_FOUND (gateway / legacy server doesn't know
|
| 365 |
+
# this method); "Session terminated" would be a lie here.
|
| 366 |
+
error_data = ErrorData(code=METHOD_NOT_FOUND, message="Not Found")
|
| 367 |
+
else:
|
| 368 |
+
error_data = ErrorData(code=INVALID_REQUEST, message="Session terminated")
|
| 369 |
+
else:
|
| 370 |
+
error_data = ErrorData(code=INTERNAL_ERROR, message="Server returned an error response")
|
| 371 |
+
session_message = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data))
|
| 372 |
+
await ctx.read_stream_writer.send(session_message)
|
| 373 |
+
return
|
| 374 |
+
|
| 375 |
+
if self._is_initialization_request(message):
|
| 376 |
+
self._maybe_extract_session_id_from_response(response)
|
| 377 |
+
|
| 378 |
+
# Per https://modelcontextprotocol.io/specification/2025-06-18/basic#notifications:
|
| 379 |
+
# The server MUST NOT send a response to notifications.
|
| 380 |
+
if isinstance(message, JSONRPCRequest):
|
| 381 |
+
content_type = response.headers.get("content-type", "").lower()
|
| 382 |
+
if content_type.startswith("application/json"):
|
| 383 |
+
await self._handle_json_response(response, ctx.read_stream_writer, request_id=message.id)
|
| 384 |
+
elif content_type.startswith("text/event-stream"):
|
| 385 |
+
await self._handle_sse_response(response, ctx)
|
| 386 |
+
else:
|
| 387 |
+
logger.error(f"Unexpected content type: {content_type}")
|
| 388 |
+
error_data = ErrorData(code=INVALID_REQUEST, message=f"Unexpected content type: {content_type}")
|
| 389 |
+
error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data))
|
| 390 |
+
await ctx.read_stream_writer.send(error_msg)
|
| 391 |
+
|
| 392 |
+
async def _handle_json_response(
|
| 393 |
+
self,
|
| 394 |
+
response: httpx2.Response,
|
| 395 |
+
read_stream_writer: StreamWriter,
|
| 396 |
+
*,
|
| 397 |
+
request_id: RequestId,
|
| 398 |
+
) -> None:
|
| 399 |
+
"""Handle JSON response from the server."""
|
| 400 |
+
try:
|
| 401 |
+
content = await response.aread()
|
| 402 |
+
message = jsonrpc_message_adapter.validate_json(content, by_name=False)
|
| 403 |
+
session_message = SessionMessage(message)
|
| 404 |
+
await read_stream_writer.send(session_message)
|
| 405 |
+
except (httpx2.StreamError, ValidationError) as exc:
|
| 406 |
+
logger.exception("Error parsing JSON response")
|
| 407 |
+
error_data = ErrorData(code=PARSE_ERROR, message=f"Failed to parse JSON response: {exc}")
|
| 408 |
+
error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=request_id, error=error_data))
|
| 409 |
+
await read_stream_writer.send(error_msg)
|
| 410 |
+
|
| 411 |
+
async def _handle_sse_response(
|
| 412 |
+
self,
|
| 413 |
+
response: httpx2.Response,
|
| 414 |
+
ctx: RequestContext,
|
| 415 |
+
) -> None:
|
| 416 |
+
"""Handle SSE response from the server."""
|
| 417 |
+
last_event_id: str | None = None
|
| 418 |
+
retry_interval_ms: int | None = None
|
| 419 |
+
|
| 420 |
+
# The caller (_handle_post_request) only reaches here inside
|
| 421 |
+
# isinstance(message, JSONRPCRequest), so this is always a JSONRPCRequest.
|
| 422 |
+
assert isinstance(ctx.session_message.message, JSONRPCRequest)
|
| 423 |
+
original_request_id = ctx.session_message.message.id
|
| 424 |
+
|
| 425 |
+
try:
|
| 426 |
+
event_source = EventSource(response)
|
| 427 |
+
async for sse in event_source: # pragma: no branch
|
| 428 |
+
# Track last event ID for potential reconnection
|
| 429 |
+
if sse.id:
|
| 430 |
+
last_event_id = sse.id
|
| 431 |
+
|
| 432 |
+
# Track retry interval from server
|
| 433 |
+
if sse.retry is not None:
|
| 434 |
+
retry_interval_ms = sse.retry
|
| 435 |
+
|
| 436 |
+
is_complete = await self._handle_sse_event(
|
| 437 |
+
sse,
|
| 438 |
+
ctx.read_stream_writer,
|
| 439 |
+
original_request_id=original_request_id,
|
| 440 |
+
resumption_callback=(ctx.metadata.on_resumption_token_update if ctx.metadata else None),
|
| 441 |
+
)
|
| 442 |
+
# If the SSE event indicates completion, like returning response/error
|
| 443 |
+
# break the loop
|
| 444 |
+
if is_complete:
|
| 445 |
+
await response.aclose()
|
| 446 |
+
return # Normal completion, no reconnect needed
|
| 447 |
+
except Exception:
|
| 448 |
+
logger.debug("SSE stream ended", exc_info=True) # pragma: lax no cover
|
| 449 |
+
|
| 450 |
+
# Stream ended without response - reconnect if we received an event with ID
|
| 451 |
+
if last_event_id is not None:
|
| 452 |
+
logger.info("SSE stream disconnected, reconnecting...")
|
| 453 |
+
await self._handle_reconnection(ctx, last_event_id, retry_interval_ms)
|
| 454 |
+
else:
|
| 455 |
+
# Not resumable: resolve the waiter, else a listen stream's consumer
|
| 456 |
+
# would hang forever instead of learning the subscription is lost.
|
| 457 |
+
await self._resolve_abandoned_request(
|
| 458 |
+
ctx.read_stream_writer, original_request_id, "SSE stream ended without a response"
|
| 459 |
+
)
|
| 460 |
+
|
| 461 |
+
async def _resolve_abandoned_request(
|
| 462 |
+
self, read_stream_writer: StreamWriter, request_id: RequestId, message: str, *, code: int = CONNECTION_CLOSED
|
| 463 |
+
) -> None:
|
| 464 |
+
"""Resolve a request whose response can never arrive with a synthesized error.
|
| 465 |
+
|
| 466 |
+
Best-effort: a closed read stream means the session is tearing down.
|
| 467 |
+
"""
|
| 468 |
+
error_data = ErrorData(code=code, message=message)
|
| 469 |
+
error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=request_id, error=error_data))
|
| 470 |
+
try:
|
| 471 |
+
await read_stream_writer.send(error_msg)
|
| 472 |
+
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
|
| 473 |
+
logger.debug("read stream closed before request %r could be resolved", request_id)
|
| 474 |
+
|
| 475 |
+
async def _handle_reconnection(
|
| 476 |
+
self,
|
| 477 |
+
ctx: RequestContext,
|
| 478 |
+
last_event_id: str,
|
| 479 |
+
retry_interval_ms: int | None = None,
|
| 480 |
+
attempt: int = 0,
|
| 481 |
+
) -> None:
|
| 482 |
+
"""Reconnect with Last-Event-ID to resume stream after server disconnect."""
|
| 483 |
+
# Only requests reconnect: every caller arrives from a request's response stream.
|
| 484 |
+
assert isinstance(ctx.session_message.message, JSONRPCRequest)
|
| 485 |
+
original_request_id = ctx.session_message.message.id
|
| 486 |
+
|
| 487 |
+
if attempt >= MAX_RECONNECTION_ATTEMPTS:
|
| 488 |
+
# Resolve on give-up: a request with no read timeout (a listen
|
| 489 |
+
# stream) would otherwise hang its caller forever.
|
| 490 |
+
logger.debug(f"Max reconnection attempts ({MAX_RECONNECTION_ATTEMPTS}) exceeded")
|
| 491 |
+
await self._resolve_abandoned_request(
|
| 492 |
+
ctx.read_stream_writer, original_request_id, "SSE stream ended and reconnection attempts were exhausted"
|
| 493 |
+
)
|
| 494 |
+
return
|
| 495 |
+
|
| 496 |
+
# Always wait - use server value or default
|
| 497 |
+
delay_ms = retry_interval_ms if retry_interval_ms is not None else DEFAULT_RECONNECTION_DELAY_MS
|
| 498 |
+
await anyio.sleep(delay_ms / 1000.0)
|
| 499 |
+
|
| 500 |
+
headers = self._prepare_headers()
|
| 501 |
+
headers[LAST_EVENT_ID] = last_event_id
|
| 502 |
+
|
| 503 |
+
try:
|
| 504 |
+
async with ctx.client.sse(self.url, headers=headers) as event_source:
|
| 505 |
+
event_source.response.raise_for_status()
|
| 506 |
+
logger.info("Reconnected to SSE stream")
|
| 507 |
+
|
| 508 |
+
# Track for potential further reconnection
|
| 509 |
+
reconnect_last_event_id: str = last_event_id
|
| 510 |
+
reconnect_retry_ms = retry_interval_ms
|
| 511 |
+
|
| 512 |
+
async for sse in event_source:
|
| 513 |
+
if sse.id: # pragma: no branch
|
| 514 |
+
reconnect_last_event_id = sse.id
|
| 515 |
+
if sse.retry is not None:
|
| 516 |
+
reconnect_retry_ms = sse.retry
|
| 517 |
+
|
| 518 |
+
is_complete = await self._handle_sse_event(
|
| 519 |
+
sse,
|
| 520 |
+
ctx.read_stream_writer,
|
| 521 |
+
original_request_id,
|
| 522 |
+
ctx.metadata.on_resumption_token_update if ctx.metadata else None,
|
| 523 |
+
)
|
| 524 |
+
if is_complete:
|
| 525 |
+
await event_source.response.aclose()
|
| 526 |
+
return
|
| 527 |
+
|
| 528 |
+
# Stream ended again without response - reconnect again (reset attempt counter)
|
| 529 |
+
logger.info("SSE stream disconnected, reconnecting...")
|
| 530 |
+
await self._handle_reconnection(ctx, reconnect_last_event_id, reconnect_retry_ms, 0)
|
| 531 |
+
except Exception as e: # pragma: no cover
|
| 532 |
+
logger.debug(f"Reconnection failed: {e}")
|
| 533 |
+
# Try to reconnect again if we still have an event ID
|
| 534 |
+
await self._handle_reconnection(ctx, last_event_id, retry_interval_ms, attempt + 1)
|
| 535 |
+
|
| 536 |
+
async def post_writer(
|
| 537 |
+
self,
|
| 538 |
+
client: httpx2.AsyncClient,
|
| 539 |
+
write_stream_reader: StreamReader,
|
| 540 |
+
read_stream_writer: StreamWriter,
|
| 541 |
+
write_stream: ContextSendStream[SessionMessage],
|
| 542 |
+
start_get_stream: Callable[[], None],
|
| 543 |
+
tg: TaskGroup,
|
| 544 |
+
) -> None:
|
| 545 |
+
"""Handle writing requests to the server."""
|
| 546 |
+
try:
|
| 547 |
+
async with write_stream_reader, read_stream_writer, write_stream:
|
| 548 |
+
|
| 549 |
+
async def _handle_message(session_message: SessionMessage) -> None:
|
| 550 |
+
message = session_message.message
|
| 551 |
+
if self._consume_modern_cancellation(session_message):
|
| 552 |
+
return
|
| 553 |
+
metadata = (
|
| 554 |
+
session_message.metadata
|
| 555 |
+
if isinstance(session_message.metadata, ClientMessageMetadata)
|
| 556 |
+
else None
|
| 557 |
+
)
|
| 558 |
+
|
| 559 |
+
# Check if this is a resumption request
|
| 560 |
+
is_resumption = bool(metadata and metadata.resumption_token)
|
| 561 |
+
|
| 562 |
+
logger.debug(f"Sending client message: {message}")
|
| 563 |
+
|
| 564 |
+
# Handle initialized notification
|
| 565 |
+
if self._is_initialized_notification(message):
|
| 566 |
+
start_get_stream()
|
| 567 |
+
|
| 568 |
+
if self._is_initialization_request(message):
|
| 569 |
+
# `initialize` is the negotiation, not a "subsequent request" — discard any
|
| 570 |
+
# probe-stamped value so the discover→fallback path can't leak it onto the handshake.
|
| 571 |
+
self._protocol_version_header = None
|
| 572 |
+
elif metadata is not None and metadata.headers is not None:
|
| 573 |
+
stamped_version = metadata.headers.get(MCP_PROTOCOL_VERSION_HEADER)
|
| 574 |
+
if stamped_version is not None:
|
| 575 |
+
self._protocol_version_header = stamped_version
|
| 576 |
+
|
| 577 |
+
ctx = RequestContext(
|
| 578 |
+
client=client,
|
| 579 |
+
session_id=self.session_id,
|
| 580 |
+
session_message=session_message,
|
| 581 |
+
metadata=metadata,
|
| 582 |
+
read_stream_writer=read_stream_writer,
|
| 583 |
+
)
|
| 584 |
+
|
| 585 |
+
async def handle_request_async():
|
| 586 |
+
if is_resumption:
|
| 587 |
+
await self._handle_resumption_request(ctx)
|
| 588 |
+
else:
|
| 589 |
+
await self._handle_post_request(ctx)
|
| 590 |
+
|
| 591 |
+
# If this is a request, start a new task to handle it
|
| 592 |
+
if isinstance(message, JSONRPCRequest):
|
| 593 |
+
# Register the abort scope before the spawn: the next
|
| 594 |
+
# message through this loop can already be the abandon
|
| 595 |
+
# signal for this id, ahead of the task ever running.
|
| 596 |
+
post = _InFlightPost(
|
| 597 |
+
scope=anyio.CancelScope(),
|
| 598 |
+
modern=self._protocol_version_header in MODERN_PROTOCOL_VERSIONS,
|
| 599 |
+
)
|
| 600 |
+
superseded = self._in_flight_posts.get(message.id)
|
| 601 |
+
if superseded is not None:
|
| 602 |
+
# A reused id means the waiter belongs to this attempt now:
|
| 603 |
+
# sever the old POST so its zombie stream cannot answer,
|
| 604 |
+
# fail, or resolve the successor's request.
|
| 605 |
+
superseded.scope.cancel()
|
| 606 |
+
self._in_flight_posts[message.id] = post
|
| 607 |
+
tg.start_soon(self._run_request_post, handle_request_async, post, message.id)
|
| 608 |
+
else:
|
| 609 |
+
await handle_request_async()
|
| 610 |
+
|
| 611 |
+
async for session_message in write_stream_reader:
|
| 612 |
+
sender_ctx = write_stream_reader.last_context
|
| 613 |
+
if sender_ctx is not None:
|
| 614 |
+
async with anyio.create_task_group() as tg_local:
|
| 615 |
+
sender_ctx.run(tg_local.start_soon, _handle_message, session_message)
|
| 616 |
+
else:
|
| 617 |
+
await _handle_message(session_message) # pragma: no cover
|
| 618 |
+
|
| 619 |
+
except Exception: # pragma: lax no cover
|
| 620 |
+
logger.exception("Error in post_writer")
|
| 621 |
+
|
| 622 |
+
async def terminate_session(self, client: httpx2.AsyncClient) -> None:
|
| 623 |
+
"""Terminate the session by sending a DELETE request."""
|
| 624 |
+
if not self.session_id:
|
| 625 |
+
return # pragma: no cover
|
| 626 |
+
|
| 627 |
+
try:
|
| 628 |
+
headers = self._prepare_headers()
|
| 629 |
+
response = await client.delete(self.url, headers=headers)
|
| 630 |
+
|
| 631 |
+
if response.status_code == 405:
|
| 632 |
+
logger.debug("Server does not allow session termination")
|
| 633 |
+
elif response.status_code not in (200, 204):
|
| 634 |
+
logger.warning(f"Session termination failed: {response.status_code}") # pragma: no cover
|
| 635 |
+
except Exception as exc: # pragma: no cover
|
| 636 |
+
logger.warning(f"Session termination failed: {exc}")
|
| 637 |
+
|
| 638 |
+
|
| 639 |
+
@asynccontextmanager
|
| 640 |
+
async def streamable_http_client(
|
| 641 |
+
url: str,
|
| 642 |
+
*,
|
| 643 |
+
http_client: httpx2.AsyncClient | None = None,
|
| 644 |
+
terminate_on_close: bool = True,
|
| 645 |
+
) -> AsyncGenerator[TransportStreams, None]:
|
| 646 |
+
"""Client transport for StreamableHTTP.
|
| 647 |
+
|
| 648 |
+
Args:
|
| 649 |
+
url: The MCP server endpoint URL.
|
| 650 |
+
http_client: Optional pre-configured httpx2.AsyncClient. If None, a default
|
| 651 |
+
client with recommended MCP timeouts will be created. To configure headers,
|
| 652 |
+
authentication, or other HTTP settings, create an httpx2.AsyncClient and pass it here.
|
| 653 |
+
terminate_on_close: If True, send a DELETE request to terminate the session when the context exits.
|
| 654 |
+
|
| 655 |
+
Yields:
|
| 656 |
+
Tuple containing:
|
| 657 |
+
- read_stream: Stream for reading messages from the server
|
| 658 |
+
- write_stream: Stream for sending messages to the server
|
| 659 |
+
|
| 660 |
+
Example:
|
| 661 |
+
See examples/snippets/clients/ for usage patterns.
|
| 662 |
+
"""
|
| 663 |
+
# Determine if we need to create and manage the client
|
| 664 |
+
client_provided = http_client is not None
|
| 665 |
+
client = http_client
|
| 666 |
+
|
| 667 |
+
if client is None:
|
| 668 |
+
# Create default client with recommended MCP timeouts
|
| 669 |
+
client = create_mcp_http_client()
|
| 670 |
+
|
| 671 |
+
transport = StreamableHTTPTransport(url)
|
| 672 |
+
|
| 673 |
+
logger.debug(f"Connecting to StreamableHTTP endpoint: {url}")
|
| 674 |
+
|
| 675 |
+
async with contextlib.AsyncExitStack() as stack:
|
| 676 |
+
# Only manage client lifecycle if we created it
|
| 677 |
+
if not client_provided:
|
| 678 |
+
await stack.enter_async_context(client)
|
| 679 |
+
|
| 680 |
+
read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0)
|
| 681 |
+
write_stream, write_stream_reader = create_context_streams[SessionMessage](0)
|
| 682 |
+
|
| 683 |
+
async with (
|
| 684 |
+
read_stream_writer,
|
| 685 |
+
read_stream,
|
| 686 |
+
write_stream,
|
| 687 |
+
write_stream_reader,
|
| 688 |
+
anyio.create_task_group() as tg,
|
| 689 |
+
):
|
| 690 |
+
|
| 691 |
+
def start_get_stream() -> None:
|
| 692 |
+
tg.start_soon(transport.handle_get_stream, client, read_stream_writer)
|
| 693 |
+
|
| 694 |
+
tg.start_soon(
|
| 695 |
+
transport.post_writer,
|
| 696 |
+
client,
|
| 697 |
+
write_stream_reader,
|
| 698 |
+
read_stream_writer,
|
| 699 |
+
write_stream,
|
| 700 |
+
start_get_stream,
|
| 701 |
+
tg,
|
| 702 |
+
)
|
| 703 |
+
|
| 704 |
+
try:
|
| 705 |
+
yield read_stream, write_stream
|
| 706 |
+
finally:
|
| 707 |
+
if transport.session_id and terminate_on_close:
|
| 708 |
+
await transport.terminate_session(client)
|
| 709 |
+
tg.cancel_scope.cancel()
|
| 710 |
+
await resync_tracer()
|
.mcp-venv/lib/python3.13/site-packages/mcp/client/subscriptions.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Client-side `subscriptions/listen` driver (2026-07-28, SEP-2575).
|
| 2 |
+
|
| 3 |
+
`listen()` opens the stream as an async context manager: entering waits for
|
| 4 |
+
the server's acknowledgment, iteration yields typed change events, a graceful
|
| 5 |
+
server close ends the loop, and an abrupt drop raises `SubscriptionLost`.
|
| 6 |
+
There is no replay and no automatic re-listen: a client that re-opens a
|
| 7 |
+
subscription refetches what it depends on.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
|
| 13 |
+
from contextlib import asynccontextmanager
|
| 14 |
+
from itertools import count
|
| 15 |
+
from typing import TYPE_CHECKING, Literal
|
| 16 |
+
|
| 17 |
+
import anyio
|
| 18 |
+
import mcp_types as types
|
| 19 |
+
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
|
| 20 |
+
|
| 21 |
+
from mcp.shared.dispatcher import CallOptions
|
| 22 |
+
from mcp.shared.exceptions import MCPError
|
| 23 |
+
from mcp.shared.subscriptions import (
|
| 24 |
+
PromptsListChanged,
|
| 25 |
+
ResourcesListChanged,
|
| 26 |
+
ResourceUpdated,
|
| 27 |
+
ServerEvent,
|
| 28 |
+
ToolsListChanged,
|
| 29 |
+
event_matches,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
if TYPE_CHECKING:
|
| 33 |
+
from mcp.client.session import ClientSession
|
| 34 |
+
|
| 35 |
+
__all__ = [
|
| 36 |
+
"ListenNotSupportedError",
|
| 37 |
+
"OnEvent",
|
| 38 |
+
"PromptsListChanged",
|
| 39 |
+
"ResourceUpdated",
|
| 40 |
+
"ResourcesListChanged",
|
| 41 |
+
"ServerEvent",
|
| 42 |
+
"Subscription",
|
| 43 |
+
"SubscriptionLost",
|
| 44 |
+
"ToolsListChanged",
|
| 45 |
+
"listen",
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
_listen_ids = count(1)
|
| 49 |
+
"""Process-wide `listen-N` sequence: string ids can never collide with a dispatcher's minted ints."""
|
| 50 |
+
|
| 51 |
+
_MAX_PENDING_EVENTS = 1024
|
| 52 |
+
"""Backlog backstop: the spec allows sub-resource URIs, so distinct pending
|
| 53 |
+
`ResourceUpdated` events are unbounded; overflowing this cap settles the
|
| 54 |
+
subscription lost rather than growing client memory."""
|
| 55 |
+
|
| 56 |
+
_SubscriptionEnd = Literal["graceful", "lost", "local"]
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class ListenNotSupportedError(RuntimeError):
|
| 60 |
+
"""`subscriptions/listen` requires a 2026-07-28 connection."""
|
| 61 |
+
|
| 62 |
+
def __init__(self, negotiated_version: str | None) -> None:
|
| 63 |
+
self.negotiated_version = negotiated_version
|
| 64 |
+
super().__init__(
|
| 65 |
+
f"subscriptions/listen is not available at protocol version {negotiated_version!r}; it requires "
|
| 66 |
+
"2026-07-28. On earlier versions use subscribe_resource() and the change notifications delivered "
|
| 67 |
+
"through message_handler."
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class SubscriptionLost(RuntimeError):
|
| 72 |
+
"""The stream ended without the server's graceful close; re-listen and refetch."""
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class ListenRoute:
|
| 76 |
+
"""Package-internal demux state for one listen stream, fed synchronously in receive order by the session."""
|
| 77 |
+
|
| 78 |
+
def __init__(self) -> None:
|
| 79 |
+
self.honored: types.SubscriptionFilter | None = None
|
| 80 |
+
self.acked = anyio.Event()
|
| 81 |
+
self.error: MCPError | None = None
|
| 82 |
+
self.end: _SubscriptionEnd | None = None
|
| 83 |
+
self._honored_uris: frozenset[str] = frozenset()
|
| 84 |
+
self._pending: dict[ServerEvent, None] = {}
|
| 85 |
+
self._wake = anyio.Event()
|
| 86 |
+
|
| 87 |
+
def set_acked(self, honored: types.SubscriptionFilter) -> None:
|
| 88 |
+
"""Record the acknowledged filter; the first ack wins."""
|
| 89 |
+
if not self.acked.is_set():
|
| 90 |
+
self.honored = honored
|
| 91 |
+
self._honored_uris = frozenset(honored.resource_subscriptions or ())
|
| 92 |
+
self.acked.set()
|
| 93 |
+
|
| 94 |
+
def deliver(self, event: ServerEvent) -> None:
|
| 95 |
+
"""Queue an event within the honored filter, deduplicated against the backlog.
|
| 96 |
+
|
| 97 |
+
Any `ResourceUpdated` is admitted once URI subscriptions were honored at
|
| 98 |
+
all: the spec allows the stamped URI to be a sub-resource of a subscribed one.
|
| 99 |
+
"""
|
| 100 |
+
if self.end is not None or self.honored is None:
|
| 101 |
+
return
|
| 102 |
+
if isinstance(event, ResourceUpdated):
|
| 103 |
+
admitted = bool(self._honored_uris)
|
| 104 |
+
else:
|
| 105 |
+
admitted = event_matches(self.honored, self._honored_uris, event)
|
| 106 |
+
if not admitted or event in self._pending:
|
| 107 |
+
return
|
| 108 |
+
if len(self._pending) >= _MAX_PENDING_EVENTS:
|
| 109 |
+
self.settle(
|
| 110 |
+
"lost",
|
| 111 |
+
error=MCPError(
|
| 112 |
+
types.INTERNAL_ERROR,
|
| 113 |
+
f"subscription backlog exceeded {_MAX_PENDING_EVENTS} unconsumed events; re-listen and refetch",
|
| 114 |
+
),
|
| 115 |
+
)
|
| 116 |
+
return
|
| 117 |
+
self._pending[event] = None
|
| 118 |
+
self._wake.set()
|
| 119 |
+
|
| 120 |
+
def settle(self, end: _SubscriptionEnd, error: MCPError | None = None) -> None:
|
| 121 |
+
"""Record the stream's end; the first reason wins and wakes both waiters."""
|
| 122 |
+
if self.end is None:
|
| 123 |
+
self.end = end
|
| 124 |
+
self.error = error
|
| 125 |
+
self.acked.set()
|
| 126 |
+
self._wake.set()
|
| 127 |
+
|
| 128 |
+
async def next_event(self) -> ServerEvent | _SubscriptionEnd:
|
| 129 |
+
"""Peek the next pending event, or the stream's end once the backlog drains.
|
| 130 |
+
|
| 131 |
+
A "local" end short-circuits the backlog; the other endings drain it first,
|
| 132 |
+
so a graceful close never swallows events that preceded it.
|
| 133 |
+
"""
|
| 134 |
+
while True:
|
| 135 |
+
# Snapshot the wake event before checking state so a deliver landing after the checks cannot be missed.
|
| 136 |
+
wake = self._wake
|
| 137 |
+
if self.end == "local":
|
| 138 |
+
return self.end
|
| 139 |
+
if self._pending:
|
| 140 |
+
return next(iter(self._pending))
|
| 141 |
+
if self.end is not None:
|
| 142 |
+
return self.end
|
| 143 |
+
await wake.wait()
|
| 144 |
+
self._wake = anyio.Event()
|
| 145 |
+
|
| 146 |
+
def consume(self, event: ServerEvent) -> None:
|
| 147 |
+
"""Remove a peeked event from the backlog."""
|
| 148 |
+
self._pending.pop(event, None)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
OnEvent = Callable[[ServerEvent], Awaitable[None]]
|
| 152 |
+
"""Per-event barrier awaited before a `Subscription` returns each event to its consumer."""
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
class Subscription:
|
| 156 |
+
"""One open `subscriptions/listen` stream: an async iterator of typed events.
|
| 157 |
+
|
| 158 |
+
Produced by `listen()` / `Client.listen()`, not constructed directly.
|
| 159 |
+
"""
|
| 160 |
+
|
| 161 |
+
def __init__(
|
| 162 |
+
self,
|
| 163 |
+
route: ListenRoute,
|
| 164 |
+
subscription_id: types.RequestId,
|
| 165 |
+
honored: types.SubscriptionFilter,
|
| 166 |
+
on_event: OnEvent | None = None,
|
| 167 |
+
):
|
| 168 |
+
self._route = route
|
| 169 |
+
self._on_event = on_event
|
| 170 |
+
self.subscription_id = subscription_id
|
| 171 |
+
"""The listen request's JSON-RPC id, stamped into every frame's `_meta`."""
|
| 172 |
+
self.honored = honored
|
| 173 |
+
"""The subset of the requested filter the server agreed to deliver."""
|
| 174 |
+
|
| 175 |
+
def __aiter__(self) -> Subscription:
|
| 176 |
+
return self
|
| 177 |
+
|
| 178 |
+
async def __anext__(self) -> ServerEvent:
|
| 179 |
+
"""Yield the next change event; the loop ends when the stream does.
|
| 180 |
+
|
| 181 |
+
Raises:
|
| 182 |
+
SubscriptionLost: the stream dropped without the server's graceful close.
|
| 183 |
+
"""
|
| 184 |
+
outcome = await self._route.next_event()
|
| 185 |
+
if isinstance(outcome, str):
|
| 186 |
+
if outcome == "lost":
|
| 187 |
+
raise SubscriptionLost(
|
| 188 |
+
f"subscription {self.subscription_id!r} ended without the server's graceful close;"
|
| 189 |
+
" re-listen and refetch"
|
| 190 |
+
) from self._route.error
|
| 191 |
+
raise StopAsyncIteration
|
| 192 |
+
if self._on_event is not None:
|
| 193 |
+
# The event stays pending while the barrier runs: a cancellation or a
|
| 194 |
+
# raising barrier leaves it for the next anext instead of dropping it.
|
| 195 |
+
await self._on_event(outcome)
|
| 196 |
+
self._route.consume(outcome)
|
| 197 |
+
return outcome
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
@asynccontextmanager
|
| 201 |
+
async def listen(
|
| 202 |
+
session: ClientSession,
|
| 203 |
+
*,
|
| 204 |
+
tools_list_changed: bool = False,
|
| 205 |
+
prompts_list_changed: bool = False,
|
| 206 |
+
resources_list_changed: bool = False,
|
| 207 |
+
resource_subscriptions: Sequence[str] = (),
|
| 208 |
+
on_event: OnEvent | None = None,
|
| 209 |
+
) -> AsyncIterator[Subscription]:
|
| 210 |
+
"""Open one `subscriptions/listen` stream on `session` (2026-07-28 only).
|
| 211 |
+
|
| 212 |
+
Entering sends the request and returns once the server's acknowledgment
|
| 213 |
+
arrives; exiting ends the subscription. `on_event` is awaited before each
|
| 214 |
+
event is returned - the seam `Client.listen` uses to finish cache eviction
|
| 215 |
+
before the consumer can refetch.
|
| 216 |
+
|
| 217 |
+
Raises:
|
| 218 |
+
ListenNotSupportedError: negotiated version predates 2026-07-28.
|
| 219 |
+
MCPError: the server rejected the request, or the connection failed pre-ack.
|
| 220 |
+
SubscriptionLost: the stream ended before it was acknowledged.
|
| 221 |
+
TimeoutError: the session's read timeout elapsed before the acknowledgment.
|
| 222 |
+
"""
|
| 223 |
+
if session.protocol_version not in MODERN_PROTOCOL_VERSIONS:
|
| 224 |
+
raise ListenNotSupportedError(session.protocol_version)
|
| 225 |
+
if isinstance(resource_subscriptions, str):
|
| 226 |
+
raise TypeError("resource_subscriptions takes a sequence of URIs, not a bare string")
|
| 227 |
+
request = types.SubscriptionsListenRequest(
|
| 228 |
+
params=types.SubscriptionsListenRequestParams(
|
| 229 |
+
notifications=types.SubscriptionFilter(
|
| 230 |
+
tools_list_changed=tools_list_changed or None,
|
| 231 |
+
prompts_list_changed=prompts_list_changed or None,
|
| 232 |
+
resources_list_changed=resources_list_changed or None,
|
| 233 |
+
resource_subscriptions=list(resource_subscriptions) or None,
|
| 234 |
+
)
|
| 235 |
+
)
|
| 236 |
+
)
|
| 237 |
+
task_group = session._task_group # pyright: ignore[reportPrivateUsage]
|
| 238 |
+
if task_group is None:
|
| 239 |
+
raise RuntimeError("listen() requires an entered session")
|
| 240 |
+
request_id: types.RequestId = f"listen-{next(_listen_ids)}"
|
| 241 |
+
data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
|
| 242 |
+
opts: CallOptions = {"request_id": request_id}
|
| 243 |
+
session._stamp(data, opts) # pyright: ignore[reportPrivateUsage]
|
| 244 |
+
driver_scope = anyio.CancelScope()
|
| 245 |
+
|
| 246 |
+
async def drive() -> None:
|
| 247 |
+
# Deliberately no result timeout: the response arrives when the stream ends.
|
| 248 |
+
with driver_scope:
|
| 249 |
+
try:
|
| 250 |
+
await session._dispatcher.send_raw_request( # pyright: ignore[reportPrivateUsage]
|
| 251 |
+
data["method"], data.get("params"), opts
|
| 252 |
+
)
|
| 253 |
+
except MCPError as error:
|
| 254 |
+
route.settle("lost", error=error)
|
| 255 |
+
return
|
| 256 |
+
except ValueError as error:
|
| 257 |
+
# A raw request id collided with our minted listen id: fail this subscription
|
| 258 |
+
# and release the route in this same slice, so it cannot consume the raw caller's ack.
|
| 259 |
+
session._unregister_listen_route(request_id) # pyright: ignore[reportPrivateUsage]
|
| 260 |
+
route.settle("lost", error=MCPError(types.INTERNAL_ERROR, str(error)))
|
| 261 |
+
return
|
| 262 |
+
# A result, whatever its body, is the spec's graceful close; with no prior ack
|
| 263 |
+
# it opens the subscription already closed.
|
| 264 |
+
route.set_acked(types.SubscriptionFilter())
|
| 265 |
+
route.settle("graceful")
|
| 266 |
+
|
| 267 |
+
# Register the demux route before the request is written so the ack cannot race it.
|
| 268 |
+
route = session._register_listen_route(request_id) # pyright: ignore[reportPrivateUsage]
|
| 269 |
+
try:
|
| 270 |
+
task_group.start_soon(drive)
|
| 271 |
+
with anyio.fail_after(session._session_read_timeout_seconds): # pyright: ignore[reportPrivateUsage]
|
| 272 |
+
await route.acked.wait()
|
| 273 |
+
if route.honored is None:
|
| 274 |
+
# Only reachable on failure paths: a graceful no-ack result acked an empty filter in drive().
|
| 275 |
+
if route.error is not None:
|
| 276 |
+
raise route.error
|
| 277 |
+
raise SubscriptionLost(f"subscription {request_id!r} ended before it was acknowledged")
|
| 278 |
+
yield Subscription(route, request_id, route.honored, on_event)
|
| 279 |
+
finally:
|
| 280 |
+
route.settle("local")
|
| 281 |
+
driver_scope.cancel()
|
| 282 |
+
session._unregister_listen_route(request_id) # pyright: ignore[reportPrivateUsage]
|
.mcp-venv/lib/python3.13/site-packages/mcp/os/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Platform-specific utilities for MCP."""
|
.mcp-venv/lib/python3.13/site-packages/mcp/os/posix/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""POSIX-specific utilities for MCP."""
|
.mcp-venv/lib/python3.13/site-packages/mcp/os/posix/utilities.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""POSIX-specific functionality for stdio client operations."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import os
|
| 5 |
+
import signal
|
| 6 |
+
from contextlib import suppress
|
| 7 |
+
|
| 8 |
+
import anyio
|
| 9 |
+
from anyio.abc import Process
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
# How often to probe for surviving group members between SIGTERM and SIGKILL.
|
| 14 |
+
_GROUP_POLL_INTERVAL = 0.01
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
async def terminate_posix_process_tree(process: Process, timeout_seconds: float = 2.0) -> None:
|
| 18 |
+
"""Terminates a process and all its descendants on POSIX.
|
| 19 |
+
|
| 20 |
+
SIGTERMs the process group, waits up to timeout_seconds for it to
|
| 21 |
+
disappear, then SIGKILLs whatever remains. killpg reaches every descendant
|
| 22 |
+
atomically, even ones whose parent already exited; daemonizers that left
|
| 23 |
+
the group escape by design. A group only disappears once every member is
|
| 24 |
+
dead and reaped, so a client running as PID 1 should reap orphans (e.g.
|
| 25 |
+
docker run --init) or the wait below runs its full timeout.
|
| 26 |
+
"""
|
| 27 |
+
# The leader's pid is the pgid (start_new_session). Never use getpgid():
|
| 28 |
+
# it fails once the leader is reaped, even with live members left.
|
| 29 |
+
pgid = process.pid
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
os.killpg(pgid, signal.SIGTERM)
|
| 33 |
+
except ProcessLookupError:
|
| 34 |
+
return # the whole group is already gone
|
| 35 |
+
except PermissionError:
|
| 36 |
+
# EPERM never proves the group is gone (macOS raises it for zombie or
|
| 37 |
+
# foreign-euid members), so keep waiting and escalating.
|
| 38 |
+
logger.warning(
|
| 39 |
+
"No permission to signal some of process group %d; waiting for it to exit anyway", pgid, exc_info=True
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
with anyio.move_on_after(timeout_seconds):
|
| 43 |
+
while _group_alive(pgid):
|
| 44 |
+
# Reading returncode reaps the leader on trio; a zombie leader would
|
| 45 |
+
# otherwise keep the group alive for the full timeout.
|
| 46 |
+
_ = process.returncode
|
| 47 |
+
await anyio.sleep(_GROUP_POLL_INTERVAL)
|
| 48 |
+
return
|
| 49 |
+
|
| 50 |
+
# ESRCH: died since the last probe. EPERM: we killed what we were allowed to.
|
| 51 |
+
with suppress(ProcessLookupError, PermissionError):
|
| 52 |
+
os.killpg(pgid, signal.SIGKILL)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _group_alive(pgid: int) -> bool:
|
| 56 |
+
"""Probes the group with signal 0; only ESRCH proves it is gone."""
|
| 57 |
+
try:
|
| 58 |
+
os.killpg(pgid, 0)
|
| 59 |
+
except ProcessLookupError:
|
| 60 |
+
return False
|
| 61 |
+
except PermissionError:
|
| 62 |
+
pass # unsignalable survivors or unreaped zombies; EPERM is ambiguous
|
| 63 |
+
return True
|
.mcp-venv/lib/python3.13/site-packages/mcp/os/win32/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Windows-specific utilities for MCP."""
|
.mcp-venv/lib/python3.13/site-packages/mcp/os/win32/utilities.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Windows-specific functionality for stdio transport operations."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import shutil
|
| 5 |
+
import subprocess
|
| 6 |
+
import sys
|
| 7 |
+
import weakref
|
| 8 |
+
from contextlib import suppress
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import BinaryIO, TextIO, TypeAlias, cast
|
| 11 |
+
|
| 12 |
+
import anyio
|
| 13 |
+
from anyio.abc import Process
|
| 14 |
+
from anyio.streams.file import FileReadStream, FileWriteStream
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
# Windows-specific imports for Job Objects
|
| 19 |
+
if sys.platform == "win32":
|
| 20 |
+
import msvcrt
|
| 21 |
+
|
| 22 |
+
import pywintypes
|
| 23 |
+
import win32api
|
| 24 |
+
import win32con
|
| 25 |
+
import win32job
|
| 26 |
+
else:
|
| 27 |
+
# Type stubs for non-Windows platforms
|
| 28 |
+
win32api = None
|
| 29 |
+
win32con = None
|
| 30 |
+
msvcrt = None
|
| 31 |
+
win32job = None
|
| 32 |
+
pywintypes = None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def rebind_std_handle_to_fd(fd: int) -> None:
|
| 36 |
+
"""Points the Win32 standard-handle slot for fd 0, 1, or 2 at fd's current OS handle.
|
| 37 |
+
|
| 38 |
+
os.dup2 updates only the CRT descriptor table; subprocess handle inheritance
|
| 39 |
+
reads the Win32 slot, so it must be repointed too.
|
| 40 |
+
|
| 41 |
+
Raises:
|
| 42 |
+
OSError: The slot could not be set.
|
| 43 |
+
"""
|
| 44 |
+
if sys.platform != "win32" or not win32api or not msvcrt or not pywintypes:
|
| 45 |
+
return
|
| 46 |
+
std_ids = {0: win32api.STD_INPUT_HANDLE, 1: win32api.STD_OUTPUT_HANDLE, 2: win32api.STD_ERROR_HANDLE}
|
| 47 |
+
try:
|
| 48 |
+
win32api.SetStdHandle(std_ids[fd], msvcrt.get_osfhandle(fd))
|
| 49 |
+
except pywintypes.error as exc:
|
| 50 |
+
# Normalized so callers' OSError-based best-effort handling covers it.
|
| 51 |
+
raise OSError(f"SetStdHandle failed for fd {fd}") from exc
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# How often FallbackProcess polls the underlying Popen for exit.
|
| 55 |
+
_EXIT_POLL_INTERVAL = 0.01
|
| 56 |
+
|
| 57 |
+
# Job Object handle per spawned process, for tree termination at shutdown.
|
| 58 |
+
# Values stay pywin32 PyHANDLEs: if no pop site ever runs, the dying weak entry
|
| 59 |
+
# drops the last reference and the PyHANDLE destructor closes the handle, which
|
| 60 |
+
# is what makes KILL_ON_JOB_CLOSE reap an abandoned tree.
|
| 61 |
+
_process_jobs: "weakref.WeakKeyDictionary[Process | FallbackProcess, object]" = weakref.WeakKeyDictionary()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def get_windows_executable_command(command: str) -> str:
|
| 65 |
+
"""Resolves the command to a Windows executable path.
|
| 66 |
+
|
| 67 |
+
Tries the bare name first, then the common script extensions (.cmd, .bat,
|
| 68 |
+
.exe, .ps1).
|
| 69 |
+
"""
|
| 70 |
+
try:
|
| 71 |
+
if command_path := shutil.which(command):
|
| 72 |
+
return command_path
|
| 73 |
+
|
| 74 |
+
for ext in [".cmd", ".bat", ".exe", ".ps1"]:
|
| 75 |
+
ext_version = f"{command}{ext}"
|
| 76 |
+
if ext_path := shutil.which(ext_version):
|
| 77 |
+
return ext_path
|
| 78 |
+
|
| 79 |
+
return command
|
| 80 |
+
except OSError:
|
| 81 |
+
return command # path probing failed (permissions, broken symlinks)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class FallbackProcess:
|
| 85 |
+
"""Async wrapper around subprocess.Popen for SelectorEventLoop.
|
| 86 |
+
|
| 87 |
+
Windows event loops without async subprocess support get this Popen-backed
|
| 88 |
+
fallback, with anyio file streams wrapping the pipes.
|
| 89 |
+
"""
|
| 90 |
+
|
| 91 |
+
def __init__(self, popen_obj: subprocess.Popen[bytes]) -> None:
|
| 92 |
+
self.popen: subprocess.Popen[bytes] = popen_obj
|
| 93 |
+
stdin = popen_obj.stdin
|
| 94 |
+
stdout = popen_obj.stdout
|
| 95 |
+
|
| 96 |
+
self.stdin = FileWriteStream(cast(BinaryIO, stdin)) if stdin else None
|
| 97 |
+
self.stdout = FileReadStream(cast(BinaryIO, stdout)) if stdout else None
|
| 98 |
+
|
| 99 |
+
async def wait(self) -> int:
|
| 100 |
+
"""Waits for exit by polling the Popen.
|
| 101 |
+
|
| 102 |
+
A thread blocked in Popen.wait() cannot be cancelled by anyio, which
|
| 103 |
+
would defeat every timeout placed around this call.
|
| 104 |
+
"""
|
| 105 |
+
while (returncode := self.popen.poll()) is None:
|
| 106 |
+
await anyio.sleep(_EXIT_POLL_INTERVAL)
|
| 107 |
+
return returncode
|
| 108 |
+
|
| 109 |
+
def terminate(self) -> None:
|
| 110 |
+
"""Terminates the subprocess."""
|
| 111 |
+
self.popen.terminate()
|
| 112 |
+
|
| 113 |
+
def kill(self) -> None:
|
| 114 |
+
"""Kills the subprocess (on Windows the same hard kill as terminate)."""
|
| 115 |
+
self.popen.kill()
|
| 116 |
+
|
| 117 |
+
@property
|
| 118 |
+
def pid(self) -> int:
|
| 119 |
+
"""Returns the process ID."""
|
| 120 |
+
return self.popen.pid
|
| 121 |
+
|
| 122 |
+
@property
|
| 123 |
+
def returncode(self) -> int | None:
|
| 124 |
+
"""The exit code, or None while the process is still running.
|
| 125 |
+
|
| 126 |
+
Polls the Popen so death is observable without anyone calling wait().
|
| 127 |
+
"""
|
| 128 |
+
return self.popen.poll()
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# The process handle stdio_client drives: anyio's Process, or the Popen-backed
|
| 132 |
+
# fallback used on Windows event loops without async subprocess support.
|
| 133 |
+
ServerProcess: TypeAlias = Process | FallbackProcess
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
async def create_windows_process(
|
| 137 |
+
command: str,
|
| 138 |
+
args: list[str],
|
| 139 |
+
env: dict[str, str] | None = None,
|
| 140 |
+
errlog: TextIO | None = sys.stderr,
|
| 141 |
+
cwd: Path | str | None = None,
|
| 142 |
+
) -> Process | FallbackProcess:
|
| 143 |
+
"""Creates a subprocess with Job Object support for tree termination.
|
| 144 |
+
|
| 145 |
+
Spawns via anyio's open_process; event loops without async subprocess
|
| 146 |
+
support (notably the SelectorEventLoop) raise NotImplementedError, in which
|
| 147 |
+
case the spawn falls back to a Popen-backed FallbackProcess. Either way the
|
| 148 |
+
process is then assigned to a Job Object so its children can be terminated
|
| 149 |
+
with it; children spawned before the assignment completes are not captured
|
| 150 |
+
(see the inline note below).
|
| 151 |
+
|
| 152 |
+
Returns:
|
| 153 |
+
Process | FallbackProcess: The spawned process with async stdin/stdout streams.
|
| 154 |
+
"""
|
| 155 |
+
try:
|
| 156 |
+
process = await anyio.open_process(
|
| 157 |
+
[command, *args],
|
| 158 |
+
env=env,
|
| 159 |
+
# Ensure we don't create console windows for each process
|
| 160 |
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
| 161 |
+
stderr=errlog,
|
| 162 |
+
cwd=cwd,
|
| 163 |
+
)
|
| 164 |
+
except NotImplementedError:
|
| 165 |
+
# Windows event loops without async subprocess support (SelectorEventLoop)
|
| 166 |
+
process = await _create_windows_fallback_process(command, args, env, errlog, cwd)
|
| 167 |
+
|
| 168 |
+
# Children spawned before the assignment completes land outside the job
|
| 169 |
+
# (membership is inherited at CreateProcess, never acquired retroactively);
|
| 170 |
+
# if that ever bites, the fix is a CREATE_SUSPENDED spawn -> assign -> resume.
|
| 171 |
+
job = _create_job_object()
|
| 172 |
+
_maybe_assign_process_to_job(process, job)
|
| 173 |
+
return process
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
async def _create_windows_fallback_process(
|
| 177 |
+
command: str,
|
| 178 |
+
args: list[str],
|
| 179 |
+
env: dict[str, str] | None = None,
|
| 180 |
+
errlog: TextIO | None = sys.stderr,
|
| 181 |
+
cwd: Path | str | None = None,
|
| 182 |
+
) -> FallbackProcess:
|
| 183 |
+
"""Spawns via subprocess.Popen and wraps it in FallbackProcess."""
|
| 184 |
+
popen_obj = subprocess.Popen(
|
| 185 |
+
[command, *args],
|
| 186 |
+
stdin=subprocess.PIPE,
|
| 187 |
+
stdout=subprocess.PIPE,
|
| 188 |
+
stderr=errlog,
|
| 189 |
+
env=env,
|
| 190 |
+
cwd=cwd,
|
| 191 |
+
bufsize=0, # Unbuffered output
|
| 192 |
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
| 193 |
+
)
|
| 194 |
+
return FallbackProcess(popen_obj)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _create_job_object() -> object | None:
|
| 198 |
+
"""Creates a Windows Job Object configured to terminate all its processes when closed."""
|
| 199 |
+
if sys.platform != "win32" or not win32api or not win32job:
|
| 200 |
+
return None
|
| 201 |
+
|
| 202 |
+
job = None
|
| 203 |
+
try:
|
| 204 |
+
job = win32job.CreateJobObject(None, "")
|
| 205 |
+
extended_info = win32job.QueryInformationJobObject(job, win32job.JobObjectExtendedLimitInformation)
|
| 206 |
+
|
| 207 |
+
extended_info["BasicLimitInformation"]["LimitFlags"] |= win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
| 208 |
+
win32job.SetInformationJobObject(job, win32job.JobObjectExtendedLimitInformation, extended_info)
|
| 209 |
+
return job
|
| 210 |
+
except pywintypes.error:
|
| 211 |
+
logger.warning("Failed to create Job Object for process tree management", exc_info=True)
|
| 212 |
+
# If creation succeeded but configuration failed, close the handle now.
|
| 213 |
+
if job is not None:
|
| 214 |
+
_close_job_handle(job)
|
| 215 |
+
return None
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _maybe_assign_process_to_job(process: Process | FallbackProcess, job: object | None) -> None:
|
| 219 |
+
"""Assigns the process to the job and records it for tree termination.
|
| 220 |
+
|
| 221 |
+
On any failure the job handle is closed instead.
|
| 222 |
+
"""
|
| 223 |
+
if job is None:
|
| 224 |
+
return
|
| 225 |
+
|
| 226 |
+
if sys.platform != "win32" or not win32api or not win32con or not win32job:
|
| 227 |
+
return
|
| 228 |
+
|
| 229 |
+
try:
|
| 230 |
+
process_handle = win32api.OpenProcess(
|
| 231 |
+
win32con.PROCESS_SET_QUOTA | win32con.PROCESS_TERMINATE, False, process.pid
|
| 232 |
+
)
|
| 233 |
+
if not process_handle:
|
| 234 |
+
raise pywintypes.error(0, "OpenProcess", "Failed to open process handle")
|
| 235 |
+
|
| 236 |
+
try:
|
| 237 |
+
win32job.AssignProcessToJobObject(job, process_handle)
|
| 238 |
+
finally:
|
| 239 |
+
win32api.CloseHandle(process_handle)
|
| 240 |
+
# Record only after the CloseHandle above succeeded: had it failed, the
|
| 241 |
+
# except below would close the job and KILL_ON_JOB_CLOSE takes the server.
|
| 242 |
+
_process_jobs[process] = job
|
| 243 |
+
except pywintypes.error:
|
| 244 |
+
logger.warning("Failed to assign process %d to Job Object", process.pid, exc_info=True)
|
| 245 |
+
_close_job_handle(job)
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def close_process_job(process: Process | FallbackProcess) -> None:
|
| 249 |
+
"""Closes the process's Job Object handle, if it still has one.
|
| 250 |
+
|
| 251 |
+
KILL_ON_JOB_CLOSE makes the close also kill any members still alive,
|
| 252 |
+
deterministically rather than at GC time; a deliberate divergence from
|
| 253 |
+
POSIX, where a graceful server's children are left alive.
|
| 254 |
+
"""
|
| 255 |
+
if sys.platform != "win32":
|
| 256 |
+
return
|
| 257 |
+
|
| 258 |
+
job = _process_jobs.pop(process, None)
|
| 259 |
+
if job is not None:
|
| 260 |
+
_close_job_handle(job)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
async def terminate_windows_process_tree(process: Process | FallbackProcess) -> None:
|
| 264 |
+
"""Terminates the process's job, or just the process if it has no job.
|
| 265 |
+
|
| 266 |
+
Job termination is an immediate hard kill of every member. Windows has no
|
| 267 |
+
tree-wide SIGTERM; the stdin-close grace period is the server's chance to
|
| 268 |
+
exit cleanly.
|
| 269 |
+
"""
|
| 270 |
+
if sys.platform != "win32":
|
| 271 |
+
return
|
| 272 |
+
|
| 273 |
+
job = _process_jobs.pop(process, None)
|
| 274 |
+
if job is not None and win32job:
|
| 275 |
+
try:
|
| 276 |
+
with suppress(pywintypes.error): # the job might already be terminated
|
| 277 |
+
win32job.TerminateJobObject(job, 1)
|
| 278 |
+
finally:
|
| 279 |
+
_close_job_handle(job)
|
| 280 |
+
|
| 281 |
+
# The process may have no job (creation or assignment failed); kill it directly too.
|
| 282 |
+
try:
|
| 283 |
+
process.terminate()
|
| 284 |
+
except OSError:
|
| 285 |
+
pass
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def _close_job_handle(job: object) -> None:
|
| 289 |
+
"""Closes a Job Object handle, tolerating one that is already closed."""
|
| 290 |
+
if win32api and pywintypes:
|
| 291 |
+
with suppress(pywintypes.error):
|
| 292 |
+
win32api.CloseHandle(job)
|
.mcp-venv/lib/python3.13/site-packages/mcp/py.typed
ADDED
|
File without changes
|
.mcp-venv/lib/python3.13/site-packages/mcp/server/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .caching import CacheHint
|
| 2 |
+
from .context import ServerRequestContext
|
| 3 |
+
from .lowlevel import NotificationOptions, Server
|
| 4 |
+
from .mcpserver import MCPServer
|
| 5 |
+
from .models import InitializationOptions
|
| 6 |
+
|
| 7 |
+
__all__ = ["CacheHint", "Server", "ServerRequestContext", "MCPServer", "NotificationOptions", "InitializationOptions"]
|
.mcp-venv/lib/python3.13/site-packages/mcp/server/__main__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import sys
|
| 3 |
+
import warnings
|
| 4 |
+
|
| 5 |
+
import anyio
|
| 6 |
+
|
| 7 |
+
from mcp.server.lowlevel.server import Server
|
| 8 |
+
from mcp.server.stdio import stdio_server
|
| 9 |
+
|
| 10 |
+
if not sys.warnoptions:
|
| 11 |
+
warnings.simplefilter("ignore")
|
| 12 |
+
|
| 13 |
+
logging.basicConfig(level=logging.INFO)
|
| 14 |
+
logger = logging.getLogger("server")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
async def main() -> None:
|
| 18 |
+
server: Server[dict[str, object]] = Server("mcp")
|
| 19 |
+
async with stdio_server() as (read_stream, write_stream):
|
| 20 |
+
await server.run(read_stream, write_stream, server.create_initialization_options())
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
if __name__ == "__main__":
|
| 24 |
+
anyio.run(main, backend="trio")
|
.mcp-venv/lib/python3.13/site-packages/mcp/server/_otel.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from mcp_types import INVALID_PARAMS, CallToolResult
|
| 6 |
+
from opentelemetry.trace import SpanKind, StatusCode
|
| 7 |
+
from pydantic import ValidationError
|
| 8 |
+
|
| 9 |
+
from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext
|
| 10 |
+
from mcp.shared._otel import extract_trace_context, otel_span
|
| 11 |
+
from mcp.shared.exceptions import MCPError
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class OpenTelemetryMiddleware(ServerMiddleware[Any]):
|
| 15 |
+
"""Context-tier middleware that wraps each inbound message in an OpenTelemetry span."""
|
| 16 |
+
|
| 17 |
+
async def __call__(self, ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult:
|
| 18 |
+
name = ctx.params.get("name") if ctx.params else None
|
| 19 |
+
target = name if isinstance(name, str) else None
|
| 20 |
+
|
| 21 |
+
attributes: dict[str, Any] = {
|
| 22 |
+
"mcp.method.name": ctx.method,
|
| 23 |
+
"mcp.protocol.version": ctx.protocol_version,
|
| 24 |
+
}
|
| 25 |
+
if ctx.request_id is not None:
|
| 26 |
+
attributes["jsonrpc.request.id"] = str(ctx.request_id)
|
| 27 |
+
|
| 28 |
+
if ctx.method == "tools/call":
|
| 29 |
+
attributes["gen_ai.operation.name"] = "execute_tool"
|
| 30 |
+
if target is not None:
|
| 31 |
+
attributes["gen_ai.tool.name"] = target
|
| 32 |
+
elif ctx.method == "prompts/get" and target is not None:
|
| 33 |
+
attributes["gen_ai.prompt.name"] = target
|
| 34 |
+
|
| 35 |
+
with otel_span(
|
| 36 |
+
name=f"{ctx.method}{f' {target}' if target else ''}",
|
| 37 |
+
kind=SpanKind.SERVER,
|
| 38 |
+
attributes=attributes,
|
| 39 |
+
context=extract_trace_context(ctx.meta),
|
| 40 |
+
record_exception=False,
|
| 41 |
+
set_status_on_exception=False,
|
| 42 |
+
) as span:
|
| 43 |
+
try:
|
| 44 |
+
result = await call_next(ctx)
|
| 45 |
+
except MCPError as e:
|
| 46 |
+
code = str(e.error.code)
|
| 47 |
+
span.set_attributes({"error.type": code, "rpc.response.status_code": code})
|
| 48 |
+
span.set_status(StatusCode.ERROR, e.error.message)
|
| 49 |
+
raise
|
| 50 |
+
except ValidationError:
|
| 51 |
+
# Mirror the sanitized wire response; pydantic messages carry client input.
|
| 52 |
+
code = str(INVALID_PARAMS)
|
| 53 |
+
span.set_attributes({"error.type": code, "rpc.response.status_code": code})
|
| 54 |
+
span.set_status(StatusCode.ERROR, "Invalid request parameters")
|
| 55 |
+
raise
|
| 56 |
+
except Exception as e:
|
| 57 |
+
span.set_attribute("error.type", type(e).__qualname__)
|
| 58 |
+
span.record_exception(e)
|
| 59 |
+
span.set_status(StatusCode.ERROR, str(e))
|
| 60 |
+
raise
|
| 61 |
+
if ctx.method == "tools/call":
|
| 62 |
+
# Tool errors are detected pre-serialization, so only shapes that reach the wire as an error
|
| 63 |
+
# count: the model, or the camelCase alias (`is_error` is dropped by the alias-only wire
|
| 64 |
+
# validation). A raw-dict `isError` is matched as a literal bool only - non-bool coercible
|
| 65 |
+
# values (1, "true") would serialize to an error but are rare enough to leave undetected.
|
| 66 |
+
match result:
|
| 67 |
+
case CallToolResult(is_error=True) | {"isError": True}:
|
| 68 |
+
span.set_attribute("error.type", "tool_error")
|
| 69 |
+
span.set_status(StatusCode.ERROR)
|
| 70 |
+
case _:
|
| 71 |
+
pass
|
| 72 |
+
return result
|
.mcp-venv/lib/python3.13/site-packages/mcp/server/_streamable_http_modern.py
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Single-exchange HTTP serving for protocol version 2026-07-28.
|
| 2 |
+
|
| 3 |
+
Private module — entry is via `StreamableHTTPSessionManager.handle_request`.
|
| 4 |
+
The legacy streamable-HTTP transport is untouched and remains the supported
|
| 5 |
+
path for earlier protocol revisions.
|
| 6 |
+
|
| 7 |
+
A 2026-07-28 request is a self-contained POST: no `initialize` handshake, no
|
| 8 |
+
`Mcp-Session-Id`, one JSON-RPC request in, one JSON-RPC response out. JSON
|
| 9 |
+
mode handles the request directly in the ASGI task. SSE mode runs the handler
|
| 10 |
+
as a sibling task and defers committing to `text/event-stream` until the
|
| 11 |
+
handler emits a notification or `_SSE_PING_INTERVAL` elapses, whichever
|
| 12 |
+
comes first: a handler that completes (or raises) within that window without
|
| 13 |
+
emitting still gets a JSON response with the table-mapped HTTP status, so
|
| 14 |
+
the spec's `404`/`400` MUSTs hold for kernel-dispatch errors; a handler that
|
| 15 |
+
runs silent past the window commits SSE so the keepalive ping can keep the
|
| 16 |
+
connection open behind a proxy idle-read timeout.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import json
|
| 22 |
+
import logging
|
| 23 |
+
from collections.abc import Awaitable, Mapping
|
| 24 |
+
from dataclasses import dataclass, field
|
| 25 |
+
from typing import TYPE_CHECKING, Any, Final, cast
|
| 26 |
+
|
| 27 |
+
import anyio
|
| 28 |
+
from anyio.streams.memory import MemoryObjectSendStream
|
| 29 |
+
from mcp_types import (
|
| 30 |
+
CLIENT_CAPABILITIES_META_KEY,
|
| 31 |
+
CLIENT_INFO_META_KEY,
|
| 32 |
+
HEADER_MISMATCH,
|
| 33 |
+
INVALID_REQUEST,
|
| 34 |
+
PARSE_ERROR,
|
| 35 |
+
PROTOCOL_VERSION_META_KEY,
|
| 36 |
+
ErrorData,
|
| 37 |
+
JSONRPCError,
|
| 38 |
+
JSONRPCNotification,
|
| 39 |
+
JSONRPCRequest,
|
| 40 |
+
JSONRPCResponse,
|
| 41 |
+
ProgressToken,
|
| 42 |
+
RequestId,
|
| 43 |
+
)
|
| 44 |
+
from mcp_types import methods as _methods
|
| 45 |
+
from pydantic import ValidationError
|
| 46 |
+
from starlette.requests import Request
|
| 47 |
+
from starlette.responses import Response
|
| 48 |
+
from starlette.types import Receive, Scope, Send
|
| 49 |
+
|
| 50 |
+
from mcp.server.connection import Connection
|
| 51 |
+
from mcp.server.runner import modern_error_data, serve_one
|
| 52 |
+
from mcp.server.streamable_http import check_accept_headers
|
| 53 |
+
from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings
|
| 54 |
+
from mcp.shared.dispatcher import CallOptions
|
| 55 |
+
from mcp.shared.exceptions import NoBackChannelError
|
| 56 |
+
from mcp.shared.inbound import (
|
| 57 |
+
ERROR_CODE_HTTP_STATUS,
|
| 58 |
+
MCP_PARAM_HEADER_PREFIX,
|
| 59 |
+
InboundLadderRejection,
|
| 60 |
+
InboundModernRoute,
|
| 61 |
+
classify_inbound_request,
|
| 62 |
+
find_duplicated_routing_header,
|
| 63 |
+
validate_mcp_param_headers,
|
| 64 |
+
)
|
| 65 |
+
from mcp.shared.jsonrpc_dispatcher import progress_token_from_params
|
| 66 |
+
from mcp.shared.message import MessageMetadata, ServerMessageMetadata
|
| 67 |
+
from mcp.shared.transport_context import TransportContext
|
| 68 |
+
|
| 69 |
+
if TYPE_CHECKING:
|
| 70 |
+
from mcp.server.lowlevel.server import Server
|
| 71 |
+
|
| 72 |
+
logger = logging.getLogger(__name__)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
_OK_STATUS = 200
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@dataclass
|
| 79 |
+
class _SingleExchangeDispatchContext:
|
| 80 |
+
"""`DispatchContext` for one inbound HTTP request.
|
| 81 |
+
|
| 82 |
+
Structurally satisfies `mcp.shared.dispatcher.DispatchContext`. The
|
| 83 |
+
back-channel is closed by construction: a 2026-07-28 server cannot send
|
| 84 |
+
requests to the client. The SSE sink, when present, carries request-scoped
|
| 85 |
+
notifications onto this request's response stream.
|
| 86 |
+
"""
|
| 87 |
+
|
| 88 |
+
transport: TransportContext
|
| 89 |
+
request_id: RequestId
|
| 90 |
+
message_metadata: MessageMetadata
|
| 91 |
+
progress_token: ProgressToken | None = None
|
| 92 |
+
sink: MemoryObjectSendStream[bytes] | None = None
|
| 93 |
+
cancel_requested: anyio.Event = field(default_factory=anyio.Event)
|
| 94 |
+
can_send_request: bool = field(default=False, init=False)
|
| 95 |
+
|
| 96 |
+
async def send_raw_request(
|
| 97 |
+
self,
|
| 98 |
+
method: str,
|
| 99 |
+
params: Mapping[str, Any] | None,
|
| 100 |
+
opts: CallOptions | None = None,
|
| 101 |
+
) -> dict[str, Any]:
|
| 102 |
+
raise NoBackChannelError(method)
|
| 103 |
+
|
| 104 |
+
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
|
| 105 |
+
if self.sink is None:
|
| 106 |
+
return
|
| 107 |
+
body = dict(params) if params is not None else None
|
| 108 |
+
try:
|
| 109 |
+
await self.sink.send(_sse_event(JSONRPCNotification(jsonrpc="2.0", method=method, params=body)))
|
| 110 |
+
except (anyio.ClosedResourceError, anyio.BrokenResourceError):
|
| 111 |
+
logger.debug("dropped %s: response stream closed", method)
|
| 112 |
+
|
| 113 |
+
async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
|
| 114 |
+
if self.progress_token is None:
|
| 115 |
+
return
|
| 116 |
+
params: dict[str, Any] = {"progressToken": self.progress_token, "progress": progress}
|
| 117 |
+
if total is not None:
|
| 118 |
+
params["total"] = total
|
| 119 |
+
if message is not None:
|
| 120 |
+
params["message"] = message
|
| 121 |
+
await self.notify("notifications/progress", params)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
async def _to_jsonrpc_response(
|
| 125 |
+
request_id: RequestId, coro: Awaitable[dict[str, Any]]
|
| 126 |
+
) -> JSONRPCResponse | JSONRPCError:
|
| 127 |
+
"""Await ``coro`` and wrap its outcome as the JSON-RPC reply for ``request_id``.
|
| 128 |
+
|
| 129 |
+
The exception-to-wire boundary for the modern HTTP entry, composed around
|
| 130 |
+
`serve_one`: `modern_error_data` maps the shared ladder and surfaces
|
| 131 |
+
anything else as a generic `INTERNAL_ERROR` so handler internals never
|
| 132 |
+
reach the wire.
|
| 133 |
+
"""
|
| 134 |
+
try:
|
| 135 |
+
result = await coro
|
| 136 |
+
except Exception as exc:
|
| 137 |
+
return JSONRPCError(jsonrpc="2.0", id=request_id, error=modern_error_data(exc))
|
| 138 |
+
return JSONRPCResponse(jsonrpc="2.0", id=request_id, result=result)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
_SSE_PING_INTERVAL: float = 15.0
|
| 142 |
+
"""Seconds between SSE comment-line keepalives once `text/event-stream` has committed."""
|
| 143 |
+
|
| 144 |
+
_SSE_HEADERS: Final[list[tuple[bytes, bytes]]] = [
|
| 145 |
+
(b"content-type", b"text/event-stream"),
|
| 146 |
+
(b"cache-control", b"no-cache, no-transform"),
|
| 147 |
+
(b"connection", b"keep-alive"),
|
| 148 |
+
(b"x-accel-buffering", b"no"),
|
| 149 |
+
]
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _sse_event(msg: JSONRPCResponse | JSONRPCError | JSONRPCNotification) -> bytes:
|
| 153 |
+
"""Serialise a JSON-RPC message as one SSE `event: message` frame.
|
| 154 |
+
|
| 155 |
+
SSE mode begins after the handler has emitted, so a `JSONRPCError` here
|
| 156 |
+
always carries the request's id; the `id: null` case lives in `_write`.
|
| 157 |
+
"""
|
| 158 |
+
body = msg.model_dump(mode="json", by_alias=True, exclude_none=True)
|
| 159 |
+
data = json.dumps(body, separators=(",", ":"))
|
| 160 |
+
return f"event: message\r\ndata: {data}\r\n\r\n".encode()
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
async def _write_rejection(
|
| 164 |
+
rejection: InboundLadderRejection,
|
| 165 |
+
request_id: RequestId,
|
| 166 |
+
scope: Scope,
|
| 167 |
+
receive: Receive,
|
| 168 |
+
send: Send,
|
| 169 |
+
) -> None:
|
| 170 |
+
"""Send a ladder rejection as its JSON-RPC error with the table-mapped HTTP status."""
|
| 171 |
+
rej = JSONRPCError(
|
| 172 |
+
jsonrpc="2.0",
|
| 173 |
+
id=request_id,
|
| 174 |
+
error=ErrorData(code=rejection.code, message=rejection.message, data=rejection.data),
|
| 175 |
+
)
|
| 176 |
+
await _write(rej, scope, receive, send)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
async def _write(
|
| 180 |
+
msg: JSONRPCResponse | JSONRPCError,
|
| 181 |
+
scope: Scope,
|
| 182 |
+
receive: Receive,
|
| 183 |
+
send: Send,
|
| 184 |
+
) -> None:
|
| 185 |
+
"""Serialise a JSON-RPC reply with the table-mapped HTTP status."""
|
| 186 |
+
status = ERROR_CODE_HTTP_STATUS.get(msg.error.code, _OK_STATUS) if isinstance(msg, JSONRPCError) else _OK_STATUS
|
| 187 |
+
body = msg.model_dump(mode="json", by_alias=True, exclude_none=True)
|
| 188 |
+
if isinstance(msg, JSONRPCError) and msg.id is None:
|
| 189 |
+
# JSON-RPC requires `id: null` to appear on the wire when the request
|
| 190 |
+
# id couldn't be parsed; `exclude_none` would otherwise drop it.
|
| 191 |
+
body["id"] = None
|
| 192 |
+
await Response(
|
| 193 |
+
json.dumps(body, separators=(",", ":")),
|
| 194 |
+
status_code=status,
|
| 195 |
+
media_type="application/json",
|
| 196 |
+
)(scope, receive, send)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
_MCP_PARAM_PREFIX_LOWER: Final = MCP_PARAM_HEADER_PREFIX.lower()
|
| 200 |
+
|
| 201 |
+
_MCP_PARAM_LIST_PAGE_CAP: Final = 100
|
| 202 |
+
"""Page cap for the schema-resolving tools/list walk: a buggy paginator degrades to a logged skip, not a hang."""
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
async def _tool_input_schema(
|
| 206 |
+
app: Server[Any],
|
| 207 |
+
request: Request,
|
| 208 |
+
request_id: RequestId,
|
| 209 |
+
verdict: InboundModernRoute,
|
| 210 |
+
lifespan_state: Any,
|
| 211 |
+
name: str,
|
| 212 |
+
) -> Any | None:
|
| 213 |
+
"""Resolve `name`'s inputSchema from the server's own registered `tools/list` handler.
|
| 214 |
+
|
| 215 |
+
The listing runs through the normal `serve_one` path, so a visibility-scoped
|
| 216 |
+
catalog yields exactly what *this* caller was advertised. Returns None
|
| 217 |
+
(caller skips validation) when the listing fails or never advertises the tool.
|
| 218 |
+
"""
|
| 219 |
+
meta = {
|
| 220 |
+
PROTOCOL_VERSION_META_KEY: verdict.protocol_version,
|
| 221 |
+
CLIENT_CAPABILITIES_META_KEY: verdict.client_capabilities,
|
| 222 |
+
}
|
| 223 |
+
if verdict.client_info is not None:
|
| 224 |
+
# Optional key: a conforming pair-only caller omits it rather than sending null.
|
| 225 |
+
meta[CLIENT_INFO_META_KEY] = verdict.client_info
|
| 226 |
+
list_params: dict[str, Any] = {"_meta": meta}
|
| 227 |
+
try:
|
| 228 |
+
_methods.validate_client_request("tools/list", verdict.protocol_version, list_params)
|
| 229 |
+
except ValidationError:
|
| 230 |
+
# Client-fault envelope: the real dispatch produces the INVALID_PARAMS
|
| 231 |
+
# reply, and anything above a debug line would let clients flood the log.
|
| 232 |
+
logger.debug("Mcp-Param header validation skipped: the request envelope fails tools/list validation")
|
| 233 |
+
return None
|
| 234 |
+
seen_cursors: set[str] = set()
|
| 235 |
+
dctx = _SingleExchangeDispatchContext(
|
| 236 |
+
transport=TransportContext(kind="streamable-http", can_send_request=False, headers=request.headers),
|
| 237 |
+
request_id=request_id,
|
| 238 |
+
message_metadata=ServerMessageMetadata(request_context=request),
|
| 239 |
+
)
|
| 240 |
+
for _ in range(_MCP_PARAM_LIST_PAGE_CAP):
|
| 241 |
+
# Fresh Connection per page: serve_one tears down the connection's exit stack on the way out.
|
| 242 |
+
connection = Connection.from_envelope(
|
| 243 |
+
verdict.protocol_version, verdict.client_info, verdict.client_capabilities
|
| 244 |
+
)
|
| 245 |
+
try:
|
| 246 |
+
result = await serve_one(
|
| 247 |
+
app, dctx, "tools/list", list_params, connection=connection, lifespan_state=lifespan_state
|
| 248 |
+
)
|
| 249 |
+
for tool in result.get("tools", []):
|
| 250 |
+
if tool.get("name") == name:
|
| 251 |
+
return tool.get("inputSchema")
|
| 252 |
+
cursor = result.get("nextCursor")
|
| 253 |
+
except Exception:
|
| 254 |
+
# Fail-open boundary by design: header validation must never break a
|
| 255 |
+
# working call path. Loud, precisely because the skip is fail-open.
|
| 256 |
+
logger.exception("Mcp-Param header validation skipped: the tools/list listing failed")
|
| 257 |
+
return None
|
| 258 |
+
if not isinstance(cursor, str):
|
| 259 |
+
# Listing exhausted without advertising `name`; dispatch owns rejecting an unknown tool.
|
| 260 |
+
return None
|
| 261 |
+
if cursor in seen_cursors:
|
| 262 |
+
logger.warning("Mcp-Param header validation skipped: the tools/list handler returned a cursor cycle")
|
| 263 |
+
return None
|
| 264 |
+
seen_cursors.add(cursor)
|
| 265 |
+
list_params = {"_meta": meta, "cursor": cursor}
|
| 266 |
+
logger.warning(
|
| 267 |
+
"Mcp-Param header validation skipped: tools/list pagination did not terminate within %d pages",
|
| 268 |
+
_MCP_PARAM_LIST_PAGE_CAP,
|
| 269 |
+
)
|
| 270 |
+
return None
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
async def _mcp_param_rejection(
|
| 274 |
+
app: Server[Any],
|
| 275 |
+
request: Request,
|
| 276 |
+
req: JSONRPCRequest,
|
| 277 |
+
verdict: InboundModernRoute,
|
| 278 |
+
lifespan_state: Any,
|
| 279 |
+
) -> InboundLadderRejection | None:
|
| 280 |
+
"""Validate a `tools/call` request's `Mcp-Param-*` headers against the called tool's schema.
|
| 281 |
+
|
| 282 |
+
Runs pre-dispatch, before any SSE machinery, so a rejection is always a
|
| 283 |
+
plain `application/json` 400 (the spec's MUST). With no `tools/list` handler
|
| 284 |
+
the catalog is undiscoverable and there is no recognized header to validate.
|
| 285 |
+
"""
|
| 286 |
+
if req.method != "tools/call" or app.get_request_handler("tools/list") is None:
|
| 287 |
+
return None
|
| 288 |
+
params = req.params or {}
|
| 289 |
+
name = params.get("name")
|
| 290 |
+
if not isinstance(name, str):
|
| 291 |
+
return None
|
| 292 |
+
raw_arguments = params.get("arguments")
|
| 293 |
+
if raw_arguments is not None and not isinstance(raw_arguments, Mapping):
|
| 294 |
+
return None
|
| 295 |
+
arguments: Mapping[str, Any] = cast("Mapping[str, Any]", raw_arguments) if raw_arguments is not None else {}
|
| 296 |
+
# ASGI guarantees lowercase header names, so no case-folding here.
|
| 297 |
+
if not arguments and not any(header.startswith(_MCP_PARAM_PREFIX_LOWER) for header in request.headers):
|
| 298 |
+
# No argument values and no `Mcp-Param-*` headers: no declaration can be violated either way.
|
| 299 |
+
return None
|
| 300 |
+
input_schema = await _tool_input_schema(app, request, req.id, verdict, lifespan_state, name)
|
| 301 |
+
if input_schema is None:
|
| 302 |
+
return None
|
| 303 |
+
return validate_mcp_param_headers(input_schema, arguments, request.headers)
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
async def handle_modern_request(
|
| 307 |
+
app: Server[Any],
|
| 308 |
+
security_settings: TransportSecuritySettings | None,
|
| 309 |
+
json_response: bool,
|
| 310 |
+
lifespan_state: Any,
|
| 311 |
+
scope: Scope,
|
| 312 |
+
receive: Receive,
|
| 313 |
+
send: Send,
|
| 314 |
+
) -> None:
|
| 315 |
+
"""ASGI handler for a single stateless-era POST.
|
| 316 |
+
|
| 317 |
+
Called from `StreamableHTTPSessionManager.handle_request` when the
|
| 318 |
+
`MCP-Protocol-Version` header names a modern revision; the manager enters
|
| 319 |
+
`app.lifespan` once at startup and passes the state in. Never sets
|
| 320 |
+
`Mcp-Session-Id`.
|
| 321 |
+
"""
|
| 322 |
+
request = Request(scope, receive)
|
| 323 |
+
|
| 324 |
+
security = TransportSecurityMiddleware(security_settings)
|
| 325 |
+
err = await security.validate_request(request, is_post=(request.method == "POST"))
|
| 326 |
+
if err is not None:
|
| 327 |
+
await err(scope, receive, send)
|
| 328 |
+
return
|
| 329 |
+
|
| 330 |
+
if request.method != "POST":
|
| 331 |
+
# HTTP-layer rejection (Allow accompanies 405 per RFC 9110) — happens
|
| 332 |
+
# before JSON-RPC parsing, so it doesn't go through `_write`.
|
| 333 |
+
await Response(status_code=405, headers={"Allow": "POST"})(scope, receive, send)
|
| 334 |
+
return
|
| 335 |
+
|
| 336 |
+
has_json, has_sse = check_accept_headers(request)
|
| 337 |
+
if not has_json or (not json_response and not has_sse):
|
| 338 |
+
await Response(status_code=406)(scope, receive, send)
|
| 339 |
+
return
|
| 340 |
+
|
| 341 |
+
body = await request.body()
|
| 342 |
+
try:
|
| 343 |
+
decoded = json.loads(body)
|
| 344 |
+
except (ValueError, RecursionError):
|
| 345 |
+
# Not just JSONDecodeError: oversized integer literals raise bare ValueError, deep nesting RecursionError.
|
| 346 |
+
rej = JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error"))
|
| 347 |
+
await _write(rej, scope, receive, send)
|
| 348 |
+
return
|
| 349 |
+
try:
|
| 350 |
+
req = JSONRPCRequest.model_validate(decoded)
|
| 351 |
+
except ValidationError:
|
| 352 |
+
# Well-formed JSON that isn't a single request object. The transport
|
| 353 |
+
# spec permits notification POSTs and gives the server two responses
|
| 354 |
+
# (202 accept / 4xx cannot-accept; streamable-http §Sending Messages
|
| 355 |
+
# item 5). The core protocol defines no client→server notifications
|
| 356 |
+
# over HTTP at 2026-07-28 (cancellation is SSE-stream close), so this
|
| 357 |
+
# entry takes the cannot-accept branch. TODO(L57): S4 owns the
|
| 358 |
+
# strict-vs-lenient choice.
|
| 359 |
+
rej = JSONRPCError(
|
| 360 |
+
jsonrpc="2.0",
|
| 361 |
+
id=None,
|
| 362 |
+
error=ErrorData(code=INVALID_REQUEST, message="Body must be a single JSON-RPC request object"),
|
| 363 |
+
)
|
| 364 |
+
await _write(rej, scope, receive, send)
|
| 365 |
+
return
|
| 366 |
+
|
| 367 |
+
if req.method == "subscriptions/listen" and not has_sse:
|
| 368 |
+
# A listen response IS a notification stream, never JSON (the
|
| 369 |
+
# json_response carve-out below), so this one method requires the
|
| 370 |
+
# SSE accept even in JSON-response mode; SSE mode gated it above.
|
| 371 |
+
await Response(status_code=406)(scope, receive, send)
|
| 372 |
+
return
|
| 373 |
+
|
| 374 |
+
duplicated = find_duplicated_routing_header(request.headers.items())
|
| 375 |
+
if duplicated is not None:
|
| 376 |
+
# The raw carrier is the only place duplicates are visible; the classifier sees a folded mapping.
|
| 377 |
+
rejection = InboundLadderRejection(code=HEADER_MISMATCH, message=f"{duplicated} header appears more than once")
|
| 378 |
+
await _write_rejection(rejection, req.id, scope, receive, send)
|
| 379 |
+
return
|
| 380 |
+
|
| 381 |
+
verdict = classify_inbound_request(decoded, headers=dict(request.headers))
|
| 382 |
+
if isinstance(verdict, InboundLadderRejection):
|
| 383 |
+
await _write_rejection(verdict, req.id, scope, receive, send)
|
| 384 |
+
return
|
| 385 |
+
|
| 386 |
+
mcp_param_rejection = await _mcp_param_rejection(app, request, req, verdict, lifespan_state)
|
| 387 |
+
if mcp_param_rejection is not None:
|
| 388 |
+
await _write_rejection(mcp_param_rejection, req.id, scope, receive, send)
|
| 389 |
+
return
|
| 390 |
+
|
| 391 |
+
connection = Connection.from_envelope(
|
| 392 |
+
verdict.protocol_version,
|
| 393 |
+
verdict.client_info,
|
| 394 |
+
verdict.client_capabilities,
|
| 395 |
+
)
|
| 396 |
+
dctx = _SingleExchangeDispatchContext(
|
| 397 |
+
transport=TransportContext(kind="streamable-http", can_send_request=False, headers=request.headers),
|
| 398 |
+
request_id=req.id,
|
| 399 |
+
message_metadata=ServerMessageMetadata(request_context=request),
|
| 400 |
+
progress_token=progress_token_from_params(req.params),
|
| 401 |
+
)
|
| 402 |
+
|
| 403 |
+
if json_response and req.method != "subscriptions/listen":
|
| 404 |
+
# A listen response IS a notification stream, so it always takes the
|
| 405 |
+
# SSE path below regardless of the JSON-response preference (the
|
| 406 |
+
# TypeScript and Go SDKs route it the same way).
|
| 407 |
+
msg = await _to_jsonrpc_response(
|
| 408 |
+
req.id, serve_one(app, dctx, req.method, req.params, connection=connection, lifespan_state=lifespan_state)
|
| 409 |
+
)
|
| 410 |
+
await _write(msg, scope, receive, send)
|
| 411 |
+
return
|
| 412 |
+
|
| 413 |
+
send_ch, recv_ch = anyio.create_memory_object_stream[bytes](0)
|
| 414 |
+
dctx.sink = send_ch
|
| 415 |
+
result: list[JSONRPCResponse | JSONRPCError] = []
|
| 416 |
+
|
| 417 |
+
async def run_handler() -> None:
|
| 418 |
+
async with send_ch:
|
| 419 |
+
result.append(
|
| 420 |
+
await _to_jsonrpc_response(
|
| 421 |
+
req.id,
|
| 422 |
+
serve_one(app, dctx, req.method, req.params, connection=connection, lifespan_state=lifespan_state),
|
| 423 |
+
)
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
async def watch_disconnect(cancel_scope: anyio.CancelScope) -> None:
|
| 427 |
+
while (await receive()).get("type") != "http.disconnect":
|
| 428 |
+
pass # pragma: no cover
|
| 429 |
+
cancel_scope.cancel()
|
| 430 |
+
|
| 431 |
+
async with recv_ch, anyio.create_task_group() as tg:
|
| 432 |
+
tg.start_soon(run_handler)
|
| 433 |
+
tg.start_soon(watch_disconnect, tg.cancel_scope)
|
| 434 |
+
|
| 435 |
+
event: bytes | None = None
|
| 436 |
+
done = False
|
| 437 |
+
with anyio.move_on_after(_SSE_PING_INTERVAL):
|
| 438 |
+
try:
|
| 439 |
+
event = await recv_ch.receive()
|
| 440 |
+
except anyio.EndOfStream:
|
| 441 |
+
done = True
|
| 442 |
+
|
| 443 |
+
if done:
|
| 444 |
+
# Handler completed within the deferral window without emitting:
|
| 445 |
+
# `application/json` with the table-mapped status. Kernel-dispatch
|
| 446 |
+
# errors (METHOD_NOT_FOUND, missing-capability, INVALID_PARAMS)
|
| 447 |
+
# resolve here in practice.
|
| 448 |
+
await _write(result[0], scope, receive, send)
|
| 449 |
+
else:
|
| 450 |
+
# First notification arrived, or the deferral window elapsed: commit
|
| 451 |
+
# `text/event-stream` and start pinging so a proxy idle-read timeout
|
| 452 |
+
# cannot close the stream (which on this path cancels the handler).
|
| 453 |
+
await send({"type": "http.response.start", "status": _OK_STATUS, "headers": _SSE_HEADERS})
|
| 454 |
+
while not done:
|
| 455 |
+
await send({"type": "http.response.body", "body": event or b": ping\r\n\r\n", "more_body": True})
|
| 456 |
+
event = None
|
| 457 |
+
with anyio.move_on_after(_SSE_PING_INTERVAL):
|
| 458 |
+
try:
|
| 459 |
+
event = await recv_ch.receive()
|
| 460 |
+
except anyio.EndOfStream:
|
| 461 |
+
done = True
|
| 462 |
+
await send({"type": "http.response.body", "body": _sse_event(result[0]), "more_body": False})
|
| 463 |
+
|
| 464 |
+
tg.cancel_scope.cancel()
|
.mcp-venv/lib/python3.13/site-packages/mcp/server/apps.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MCP Apps extension (`io.modelcontextprotocol/ui`).
|
| 2 |
+
|
| 3 |
+
MCP Apps lets a tool carry a reference to an interactive UI: the tool's
|
| 4 |
+
`_meta.ui.resourceUri` points at a `ui://` resource (an HTML document served
|
| 5 |
+
with the `text/html;profile=mcp-app` MIME type) that the host renders in a
|
| 6 |
+
sandboxed iframe. See https://modelcontextprotocol.io/specification/draft/extensions/apps
|
| 7 |
+
and the ext-apps spec for the wire format, and SEP-2133 for the extension framework.
|
| 8 |
+
|
| 9 |
+
This is a self-contained, additive `Extension`: it contributes tools and
|
| 10 |
+
resources and advertises the capability, but does not intercept any core method.
|
| 11 |
+
A server opts in by passing an `Apps` instance to `MCPServer(extensions=[...])`.
|
| 12 |
+
|
| 13 |
+
apps = Apps()
|
| 14 |
+
|
| 15 |
+
@apps.tool(resource_uri="ui://clock/app.html", description="Current time")
|
| 16 |
+
def get_time(ctx: Context) -> str:
|
| 17 |
+
return datetime.now(timezone.utc).isoformat()
|
| 18 |
+
|
| 19 |
+
apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)
|
| 20 |
+
|
| 21 |
+
mcp = MCPServer("clock", extensions=[apps])
|
| 22 |
+
|
| 23 |
+
Per SEP-2133, an extension MUST degrade gracefully: a UI-enabled tool should
|
| 24 |
+
still return meaningful text for clients that did not negotiate Apps. Use
|
| 25 |
+
`client_supports_apps(ctx)` to branch on the client's advertised support. (The SDK
|
| 26 |
+
keeps Apps in-core under `mcp.server.apps` rather than a separate package; the
|
| 27 |
+
TypeScript and C# SDKs ship it as a standalone package.)
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
from collections.abc import Callable, Sequence
|
| 33 |
+
from typing import Any, Literal, TypeVar
|
| 34 |
+
|
| 35 |
+
from pydantic import BaseModel, ConfigDict
|
| 36 |
+
from pydantic.alias_generators import to_camel
|
| 37 |
+
|
| 38 |
+
from mcp.server.context import ServerRequestContext
|
| 39 |
+
from mcp.server.extension import Extension, ResourceBinding, ToolBinding
|
| 40 |
+
from mcp.server.mcpserver.context import Context
|
| 41 |
+
from mcp.server.mcpserver.resources import Resource, TextResource
|
| 42 |
+
|
| 43 |
+
EXTENSION_ID = "io.modelcontextprotocol/ui"
|
| 44 |
+
"""The MCP Apps extension identifier (the shipped TS/C# constant)."""
|
| 45 |
+
|
| 46 |
+
APP_MIME_TYPE = "text/html;profile=mcp-app"
|
| 47 |
+
"""MIME type for a `ui://` app resource."""
|
| 48 |
+
|
| 49 |
+
Visibility = Literal["model", "app"]
|
| 50 |
+
"""Where a UI-bound tool is surfaced (`_meta.ui.visibility`)."""
|
| 51 |
+
|
| 52 |
+
_CallableT = TypeVar("_CallableT", bound=Callable[..., Any])
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class ResourcePermissions(BaseModel):
|
| 56 |
+
"""Iframe permissions a `ui://` resource requests (`_meta.ui.permissions`)."""
|
| 57 |
+
|
| 58 |
+
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
| 59 |
+
|
| 60 |
+
camera: dict[str, Any] | None = None
|
| 61 |
+
microphone: dict[str, Any] | None = None
|
| 62 |
+
geolocation: dict[str, Any] | None = None
|
| 63 |
+
clipboard_write: dict[str, Any] | None = None
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class ResourceCsp(BaseModel):
|
| 67 |
+
"""Content-Security-Policy domains for a `ui://` resource (`_meta.ui.csp`)."""
|
| 68 |
+
|
| 69 |
+
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
| 70 |
+
|
| 71 |
+
connect_domains: list[str] | None = None
|
| 72 |
+
resource_domains: list[str] | None = None
|
| 73 |
+
frame_domains: list[str] | None = None
|
| 74 |
+
base_uri_domains: list[str] | None = None
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class Apps(Extension):
|
| 78 |
+
"""The MCP Apps extension: bind tools to `ui://` UI resources.
|
| 79 |
+
|
| 80 |
+
Register UI-bound tools with `@apps.tool(resource_uri=...)` and their HTML
|
| 81 |
+
with `add_html_resource(...)`, then pass the instance to
|
| 82 |
+
`MCPServer(extensions=[apps])`.
|
| 83 |
+
"""
|
| 84 |
+
|
| 85 |
+
identifier = EXTENSION_ID
|
| 86 |
+
|
| 87 |
+
def __init__(self) -> None:
|
| 88 |
+
self._tools: list[tuple[ToolBinding, str]] = [] # (binding, bound resource_uri)
|
| 89 |
+
self._resources: list[ResourceBinding] = []
|
| 90 |
+
|
| 91 |
+
def tool(
|
| 92 |
+
self,
|
| 93 |
+
*,
|
| 94 |
+
resource_uri: str,
|
| 95 |
+
visibility: Sequence[Visibility] | None = None,
|
| 96 |
+
meta: dict[str, Any] | None = None,
|
| 97 |
+
**tool_kwargs: Any,
|
| 98 |
+
) -> Callable[[_CallableT], _CallableT]:
|
| 99 |
+
"""Decorator registering a tool bound to a `ui://` resource.
|
| 100 |
+
|
| 101 |
+
Stamps `_meta.ui.resourceUri` (and `_meta.ui.visibility` when given) on the
|
| 102 |
+
tool. `tool_kwargs` are forwarded to `MCPServer.add_tool` (name, title,
|
| 103 |
+
description, annotations, ...); pass `meta=` to merge extra `_meta` keys
|
| 104 |
+
alongside the `ui` entry.
|
| 105 |
+
|
| 106 |
+
Args:
|
| 107 |
+
resource_uri: The `ui://` URI of the UI resource this tool renders.
|
| 108 |
+
visibility: Where the tool is surfaced (`["model", "app"]`).
|
| 109 |
+
meta: Additional `_meta` keys to merge with the `ui` entry.
|
| 110 |
+
|
| 111 |
+
Raises:
|
| 112 |
+
ValueError: If `resource_uri` does not use the `ui://` scheme, or
|
| 113 |
+
`meta` carries a `"ui"` key (the decorator owns `_meta["ui"]`).
|
| 114 |
+
"""
|
| 115 |
+
_require_ui_scheme(resource_uri)
|
| 116 |
+
if meta and "ui" in meta:
|
| 117 |
+
raise ValueError("Apps.tool() owns _meta['ui']; pass resource_uri=/visibility= instead of a 'ui' meta key")
|
| 118 |
+
ui: dict[str, Any] = {"resourceUri": resource_uri}
|
| 119 |
+
if visibility is not None:
|
| 120 |
+
ui["visibility"] = list(visibility)
|
| 121 |
+
|
| 122 |
+
def decorator(fn: _CallableT) -> _CallableT:
|
| 123 |
+
binding = ToolBinding(fn=fn, meta={**(meta or {}), "ui": ui}, kwargs=tool_kwargs)
|
| 124 |
+
self._tools.append((binding, resource_uri))
|
| 125 |
+
return fn
|
| 126 |
+
|
| 127 |
+
return decorator
|
| 128 |
+
|
| 129 |
+
def add_html_resource(
|
| 130 |
+
self,
|
| 131 |
+
uri: str,
|
| 132 |
+
html: str,
|
| 133 |
+
*,
|
| 134 |
+
name: str | None = None,
|
| 135 |
+
title: str | None = None,
|
| 136 |
+
description: str | None = None,
|
| 137 |
+
csp: ResourceCsp | None = None,
|
| 138 |
+
permissions: ResourcePermissions | None = None,
|
| 139 |
+
domain: str | None = None,
|
| 140 |
+
prefers_border: bool | None = None,
|
| 141 |
+
) -> None:
|
| 142 |
+
"""Register a `ui://` HTML resource served as `text/html;profile=mcp-app`.
|
| 143 |
+
|
| 144 |
+
`csp`, `permissions`, `domain`, and `prefers_border` populate the
|
| 145 |
+
resource's `_meta.ui` per the ext-apps spec.
|
| 146 |
+
|
| 147 |
+
Args:
|
| 148 |
+
uri: The `ui://` URI; a tool references it via `resource_uri`.
|
| 149 |
+
html: The HTML document the host renders.
|
| 150 |
+
|
| 151 |
+
Raises:
|
| 152 |
+
ValueError: If `uri` does not use the `ui://` scheme.
|
| 153 |
+
"""
|
| 154 |
+
ui: dict[str, Any] = {}
|
| 155 |
+
if csp is not None:
|
| 156 |
+
ui["csp"] = csp.model_dump(by_alias=True, exclude_none=True)
|
| 157 |
+
if permissions is not None:
|
| 158 |
+
ui["permissions"] = permissions.model_dump(by_alias=True, exclude_none=True)
|
| 159 |
+
if domain is not None:
|
| 160 |
+
ui["domain"] = domain
|
| 161 |
+
if prefers_border is not None:
|
| 162 |
+
ui["prefersBorder"] = prefers_border
|
| 163 |
+
self.add_resource(
|
| 164 |
+
TextResource(
|
| 165 |
+
uri=uri,
|
| 166 |
+
name=name or uri,
|
| 167 |
+
title=title,
|
| 168 |
+
description=description,
|
| 169 |
+
mime_type=APP_MIME_TYPE,
|
| 170 |
+
meta={"ui": ui} if ui else None,
|
| 171 |
+
text=html,
|
| 172 |
+
)
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
def add_resource(self, resource: Resource) -> None:
|
| 176 |
+
"""Register a pre-built `ui://` resource.
|
| 177 |
+
|
| 178 |
+
The escape hatch for resources `add_html_resource` cannot express (e.g. a
|
| 179 |
+
`FileResource` serving HTML from disk). A resource without an explicit
|
| 180 |
+
`mime_type` is served as `text/html;profile=mcp-app` — hosts will not
|
| 181 |
+
render a `ui://` resource under any other MIME type, so an explicit
|
| 182 |
+
mismatch is rejected.
|
| 183 |
+
|
| 184 |
+
Raises:
|
| 185 |
+
ValueError: If the resource URI does not use the `ui://` scheme, or
|
| 186 |
+
its explicit `mime_type` is not `text/html;profile=mcp-app`.
|
| 187 |
+
"""
|
| 188 |
+
_require_ui_scheme(resource.uri)
|
| 189 |
+
if "mime_type" not in resource.model_fields_set:
|
| 190 |
+
resource = resource.model_copy(update={"mime_type": APP_MIME_TYPE})
|
| 191 |
+
elif resource.mime_type != APP_MIME_TYPE:
|
| 192 |
+
raise ValueError(f"MCP Apps resources are served as {APP_MIME_TYPE!r}, got {resource.mime_type!r}")
|
| 193 |
+
self._resources.append(ResourceBinding(resource=resource))
|
| 194 |
+
|
| 195 |
+
def tools(self) -> Sequence[ToolBinding]:
|
| 196 |
+
"""The bound tools.
|
| 197 |
+
|
| 198 |
+
Raises:
|
| 199 |
+
ValueError: If a tool's `resource_uri` has no matching resource
|
| 200 |
+
registered on this instance — a tool advertising a
|
| 201 |
+
`_meta.ui.resourceUri` that 404s on `resources/read` is a
|
| 202 |
+
misconfiguration, caught when the server consumes the extension.
|
| 203 |
+
"""
|
| 204 |
+
registered = {binding.resource.uri for binding in self._resources}
|
| 205 |
+
for tool, uri in self._tools:
|
| 206 |
+
if uri not in registered:
|
| 207 |
+
raise ValueError(
|
| 208 |
+
f"Apps tool {tool.fn.__name__!r} binds resource_uri {uri!r}, but no such resource "
|
| 209 |
+
"is registered; add it with add_html_resource() or add_resource()"
|
| 210 |
+
)
|
| 211 |
+
return [tool for tool, _ in self._tools]
|
| 212 |
+
|
| 213 |
+
def resources(self) -> Sequence[ResourceBinding]:
|
| 214 |
+
return self._resources
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def client_supports_apps(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> bool:
|
| 218 |
+
"""Whether the connected client negotiated MCP Apps support.
|
| 219 |
+
|
| 220 |
+
Returns `True` only when the client advertised the extension AND listed the
|
| 221 |
+
`text/html;profile=mcp-app` MIME type in its settings, so a UI-enabled tool
|
| 222 |
+
can fall back to text-only output otherwise.
|
| 223 |
+
"""
|
| 224 |
+
capabilities = _client_capabilities(ctx)
|
| 225 |
+
extensions = capabilities.extensions if capabilities else None
|
| 226 |
+
settings = extensions.get(EXTENSION_ID) if extensions else None
|
| 227 |
+
if settings is None:
|
| 228 |
+
return False
|
| 229 |
+
mime_types = settings.get("mimeTypes")
|
| 230 |
+
return isinstance(mime_types, list | tuple) and APP_MIME_TYPE in mime_types
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def _client_capabilities(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> Any:
|
| 234 |
+
if isinstance(ctx, Context):
|
| 235 |
+
return ctx.client_capabilities
|
| 236 |
+
return ctx.session.client_capabilities
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def _require_ui_scheme(uri: str) -> None:
|
| 240 |
+
if not uri.startswith("ui://"):
|
| 241 |
+
raise ValueError(f"MCP Apps URIs must use the ui:// scheme, got {uri!r}")
|
.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""MCP OAuth server authorization components."""
|
.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/errors.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import ValidationError
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def stringify_pydantic_error(validation_error: ValidationError) -> str:
|
| 5 |
+
return "\n".join(f"{'.'.join(str(loc) for loc in e['loc'])}: {e['msg']}" for e in validation_error.errors())
|
.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Request handlers for MCP authorization endpoints."""
|
.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/authorize.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
from typing import Any, Literal
|
| 4 |
+
|
| 5 |
+
# TODO(Marcelo): We should drop the `RootModel`.
|
| 6 |
+
from pydantic import AnyUrl, BaseModel, Field, RootModel, ValidationError # noqa: TID251
|
| 7 |
+
from starlette.datastructures import FormData, QueryParams
|
| 8 |
+
from starlette.requests import Request
|
| 9 |
+
from starlette.responses import RedirectResponse, Response
|
| 10 |
+
|
| 11 |
+
from mcp.server.auth.errors import stringify_pydantic_error
|
| 12 |
+
from mcp.server.auth.json_response import PydanticJSONResponse
|
| 13 |
+
from mcp.server.auth.provider import (
|
| 14 |
+
AuthorizationErrorCode,
|
| 15 |
+
AuthorizationParams,
|
| 16 |
+
AuthorizeError,
|
| 17 |
+
OAuthAuthorizationServerProvider,
|
| 18 |
+
construct_redirect_uri,
|
| 19 |
+
)
|
| 20 |
+
from mcp.shared.auth import InvalidRedirectUriError, InvalidScopeError
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class AuthorizationRequest(BaseModel):
|
| 26 |
+
# See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
|
| 27 |
+
client_id: str = Field(..., description="The client ID")
|
| 28 |
+
redirect_uri: AnyUrl | None = Field(None, description="URL to redirect to after authorization")
|
| 29 |
+
|
| 30 |
+
# see OAuthClientMetadata; we only support `code`
|
| 31 |
+
response_type: Literal["code"] = Field(..., description="Must be 'code' for authorization code flow")
|
| 32 |
+
code_challenge: str = Field(..., description="PKCE code challenge")
|
| 33 |
+
code_challenge_method: Literal["S256"] = Field("S256", description="PKCE code challenge method, must be S256")
|
| 34 |
+
state: str | None = Field(None, description="Optional state parameter")
|
| 35 |
+
scope: str | None = Field(
|
| 36 |
+
None,
|
| 37 |
+
description="Optional scope; if specified, should be a space-separated list of scope strings",
|
| 38 |
+
)
|
| 39 |
+
resource: str | None = Field(
|
| 40 |
+
None,
|
| 41 |
+
description="RFC 8707 resource indicator - the MCP server this token will be used with",
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class AuthorizationErrorResponse(BaseModel):
|
| 46 |
+
error: AuthorizationErrorCode
|
| 47 |
+
error_description: str | None
|
| 48 |
+
error_uri: AnyUrl | None = None
|
| 49 |
+
# must be set if provided in the request
|
| 50 |
+
state: str | None = None
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def best_effort_extract_string(key: str, params: None | FormData | QueryParams) -> str | None:
|
| 54 |
+
if params is None: # pragma: no cover
|
| 55 |
+
return None
|
| 56 |
+
value = params.get(key)
|
| 57 |
+
if isinstance(value, str):
|
| 58 |
+
return value
|
| 59 |
+
return None
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class AnyUrlModel(RootModel[AnyUrl]):
|
| 63 |
+
root: AnyUrl
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@dataclass
|
| 67 |
+
class AuthorizationHandler:
|
| 68 |
+
provider: OAuthAuthorizationServerProvider[Any, Any, Any]
|
| 69 |
+
|
| 70 |
+
async def handle(self, request: Request) -> Response:
|
| 71 |
+
# implements authorization requests for grant_type=code;
|
| 72 |
+
# see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
|
| 73 |
+
|
| 74 |
+
state = None
|
| 75 |
+
redirect_uri = None
|
| 76 |
+
client = None
|
| 77 |
+
params = None
|
| 78 |
+
|
| 79 |
+
async def error_response(
|
| 80 |
+
error: AuthorizationErrorCode,
|
| 81 |
+
error_description: str | None,
|
| 82 |
+
attempt_load_client: bool = True,
|
| 83 |
+
):
|
| 84 |
+
# Error responses take two different formats:
|
| 85 |
+
# 1. The request has a valid client ID & redirect_uri: we issue a redirect
|
| 86 |
+
# back to the redirect_uri with the error response fields as query
|
| 87 |
+
# parameters. This allows the client to be notified of the error.
|
| 88 |
+
# 2. Otherwise, we return an error response directly to the end user;
|
| 89 |
+
# we choose to do so in JSON, but this is left undefined in the
|
| 90 |
+
# specification.
|
| 91 |
+
# See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1
|
| 92 |
+
#
|
| 93 |
+
# This logic is a bit awkward to handle, because the error might be thrown
|
| 94 |
+
# very early in request validation, before we've done the usual Pydantic
|
| 95 |
+
# validation, loaded the client, etc. To handle this, error_response()
|
| 96 |
+
# contains fallback logic which attempts to load the parameters directly
|
| 97 |
+
# from the request.
|
| 98 |
+
|
| 99 |
+
nonlocal client, redirect_uri, state
|
| 100 |
+
if client is None and attempt_load_client:
|
| 101 |
+
# make last-ditch attempt to load the client
|
| 102 |
+
client_id = best_effort_extract_string("client_id", params)
|
| 103 |
+
client = await self.provider.get_client(client_id) if client_id else None
|
| 104 |
+
if redirect_uri is None and client:
|
| 105 |
+
# make last-ditch effort to load the redirect uri
|
| 106 |
+
try:
|
| 107 |
+
if params is not None and "redirect_uri" not in params:
|
| 108 |
+
raw_redirect_uri = None
|
| 109 |
+
else:
|
| 110 |
+
raw_redirect_uri = AnyUrlModel.model_validate(
|
| 111 |
+
best_effort_extract_string("redirect_uri", params)
|
| 112 |
+
).root
|
| 113 |
+
redirect_uri = client.validate_redirect_uri(raw_redirect_uri)
|
| 114 |
+
except (ValidationError, InvalidRedirectUriError):
|
| 115 |
+
# if the redirect URI is invalid, ignore it & just return the
|
| 116 |
+
# initial error
|
| 117 |
+
pass
|
| 118 |
+
|
| 119 |
+
# the error response MUST contain the state specified by the client, if any
|
| 120 |
+
if state is None:
|
| 121 |
+
# make last-ditch effort to load state
|
| 122 |
+
state = best_effort_extract_string("state", params)
|
| 123 |
+
|
| 124 |
+
error_resp = AuthorizationErrorResponse(
|
| 125 |
+
error=error,
|
| 126 |
+
error_description=error_description,
|
| 127 |
+
state=state,
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
if redirect_uri and client:
|
| 131 |
+
return RedirectResponse(
|
| 132 |
+
url=construct_redirect_uri(str(redirect_uri), **error_resp.model_dump(exclude_none=True)),
|
| 133 |
+
status_code=302,
|
| 134 |
+
headers={"Cache-Control": "no-store"},
|
| 135 |
+
)
|
| 136 |
+
else:
|
| 137 |
+
return PydanticJSONResponse(
|
| 138 |
+
status_code=400,
|
| 139 |
+
content=error_resp,
|
| 140 |
+
headers={"Cache-Control": "no-store"},
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
try:
|
| 144 |
+
# Parse request parameters
|
| 145 |
+
if request.method == "GET":
|
| 146 |
+
# Convert query_params to dict for pydantic validation
|
| 147 |
+
params = request.query_params
|
| 148 |
+
else:
|
| 149 |
+
# Parse form data for POST requests
|
| 150 |
+
params = await request.form()
|
| 151 |
+
|
| 152 |
+
# Save state if it exists, even before validation
|
| 153 |
+
state = best_effort_extract_string("state", params)
|
| 154 |
+
|
| 155 |
+
try:
|
| 156 |
+
auth_request = AuthorizationRequest.model_validate(params)
|
| 157 |
+
state = auth_request.state # Update with validated state
|
| 158 |
+
except ValidationError as validation_error:
|
| 159 |
+
error: AuthorizationErrorCode = "invalid_request"
|
| 160 |
+
for e in validation_error.errors():
|
| 161 |
+
if e["loc"] == ("response_type",) and e["type"] == "literal_error":
|
| 162 |
+
error = "unsupported_response_type"
|
| 163 |
+
break
|
| 164 |
+
return await error_response(error, stringify_pydantic_error(validation_error))
|
| 165 |
+
|
| 166 |
+
# Get client information
|
| 167 |
+
client = await self.provider.get_client(
|
| 168 |
+
auth_request.client_id,
|
| 169 |
+
)
|
| 170 |
+
if not client:
|
| 171 |
+
# For client_id validation errors, return direct error (no redirect)
|
| 172 |
+
return await error_response(
|
| 173 |
+
error="invalid_request",
|
| 174 |
+
error_description=f"Client ID '{auth_request.client_id}' not found",
|
| 175 |
+
attempt_load_client=False,
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
# Validate redirect_uri against client's registered URIs
|
| 179 |
+
try:
|
| 180 |
+
redirect_uri = client.validate_redirect_uri(auth_request.redirect_uri)
|
| 181 |
+
except InvalidRedirectUriError as validation_error:
|
| 182 |
+
# For redirect_uri validation errors, return direct error (no redirect)
|
| 183 |
+
return await error_response(
|
| 184 |
+
error="invalid_request",
|
| 185 |
+
error_description=validation_error.message,
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
# Validate scope - for scope errors, we can redirect
|
| 189 |
+
try:
|
| 190 |
+
scopes = client.validate_scope(auth_request.scope)
|
| 191 |
+
except InvalidScopeError as validation_error:
|
| 192 |
+
# For scope errors, redirect with error parameters
|
| 193 |
+
return await error_response(
|
| 194 |
+
error="invalid_scope",
|
| 195 |
+
error_description=validation_error.message,
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
# Setup authorization parameters
|
| 199 |
+
auth_params = AuthorizationParams(
|
| 200 |
+
state=state,
|
| 201 |
+
scopes=scopes,
|
| 202 |
+
code_challenge=auth_request.code_challenge,
|
| 203 |
+
redirect_uri=redirect_uri,
|
| 204 |
+
redirect_uri_provided_explicitly=auth_request.redirect_uri is not None,
|
| 205 |
+
resource=auth_request.resource, # RFC 8707
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
try:
|
| 209 |
+
# Let the provider pick the next URI to redirect to
|
| 210 |
+
return RedirectResponse(
|
| 211 |
+
url=await self.provider.authorize(
|
| 212 |
+
client,
|
| 213 |
+
auth_params,
|
| 214 |
+
),
|
| 215 |
+
status_code=302,
|
| 216 |
+
headers={"Cache-Control": "no-store"},
|
| 217 |
+
)
|
| 218 |
+
except AuthorizeError as e:
|
| 219 |
+
# Handle authorization errors as defined in RFC 6749 Section 4.1.2.1
|
| 220 |
+
return await error_response(error=e.error, error_description=e.error_description)
|
| 221 |
+
|
| 222 |
+
except Exception as validation_error: # pragma: no cover
|
| 223 |
+
# Catch-all for unexpected errors
|
| 224 |
+
logger.exception("Unexpected error in authorization_handler", exc_info=validation_error)
|
| 225 |
+
return await error_response(error="server_error", error_description="An unexpected error occurred")
|
.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/metadata.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass
|
| 2 |
+
|
| 3 |
+
from starlette.requests import Request
|
| 4 |
+
from starlette.responses import Response
|
| 5 |
+
|
| 6 |
+
from mcp.server.auth.json_response import PydanticJSONResponse
|
| 7 |
+
from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass
|
| 11 |
+
class MetadataHandler:
|
| 12 |
+
metadata: OAuthMetadata
|
| 13 |
+
|
| 14 |
+
async def handle(self, request: Request) -> Response:
|
| 15 |
+
return PydanticJSONResponse(
|
| 16 |
+
content=self.metadata,
|
| 17 |
+
headers={"Cache-Control": "public, max-age=3600"}, # Cache for 1 hour
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class ProtectedResourceMetadataHandler:
|
| 23 |
+
metadata: ProtectedResourceMetadata
|
| 24 |
+
|
| 25 |
+
async def handle(self, request: Request) -> Response:
|
| 26 |
+
return PydanticJSONResponse(
|
| 27 |
+
content=self.metadata,
|
| 28 |
+
headers={"Cache-Control": "public, max-age=3600"}, # Cache for 1 hour
|
| 29 |
+
)
|
.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/register.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import secrets
|
| 2 |
+
import time
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Any
|
| 5 |
+
from uuid import uuid4
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel, ValidationError
|
| 8 |
+
from starlette.requests import Request
|
| 9 |
+
from starlette.responses import Response
|
| 10 |
+
|
| 11 |
+
from mcp.server.auth.errors import stringify_pydantic_error
|
| 12 |
+
from mcp.server.auth.json_response import PydanticJSONResponse
|
| 13 |
+
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, RegistrationError, RegistrationErrorCode
|
| 14 |
+
from mcp.server.auth.settings import ClientRegistrationOptions
|
| 15 |
+
from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthClientMetadata
|
| 16 |
+
|
| 17 |
+
# this alias is a no-op; it's just to separate out the types exposed to the
|
| 18 |
+
# provider from what we use in the HTTP handler
|
| 19 |
+
RegistrationRequest = OAuthClientMetadata
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class RegistrationErrorResponse(BaseModel):
|
| 23 |
+
error: RegistrationErrorCode
|
| 24 |
+
error_description: str | None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass
|
| 28 |
+
class RegistrationHandler:
|
| 29 |
+
provider: OAuthAuthorizationServerProvider[Any, Any, Any]
|
| 30 |
+
options: ClientRegistrationOptions
|
| 31 |
+
|
| 32 |
+
async def handle(self, request: Request) -> Response:
|
| 33 |
+
# Implements dynamic client registration as defined in https://datatracker.ietf.org/doc/html/rfc7591#section-3.1
|
| 34 |
+
try:
|
| 35 |
+
body = await request.body()
|
| 36 |
+
client_metadata = OAuthClientMetadata.model_validate_json(body)
|
| 37 |
+
|
| 38 |
+
# Scope validation is handled below
|
| 39 |
+
except ValidationError as validation_error:
|
| 40 |
+
return PydanticJSONResponse(
|
| 41 |
+
content=RegistrationErrorResponse(
|
| 42 |
+
error="invalid_client_metadata",
|
| 43 |
+
error_description=stringify_pydantic_error(validation_error),
|
| 44 |
+
),
|
| 45 |
+
status_code=400,
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
client_id = str(uuid4())
|
| 49 |
+
|
| 50 |
+
# If auth method is None, default to client_secret_post
|
| 51 |
+
if client_metadata.token_endpoint_auth_method is None:
|
| 52 |
+
client_metadata.token_endpoint_auth_method = "client_secret_post"
|
| 53 |
+
# This server authenticates token requests with the client secret it mints; it holds
|
| 54 |
+
# no client key to verify a private_key_jwt assertion, so confirming that method would
|
| 55 |
+
# register a client whose every token request is then rejected. Refuse it instead
|
| 56 |
+
# (RFC 7591 §3.2.2), before minting credentials the client could never use.
|
| 57 |
+
if client_metadata.token_endpoint_auth_method == "private_key_jwt":
|
| 58 |
+
return PydanticJSONResponse(
|
| 59 |
+
content=RegistrationErrorResponse(
|
| 60 |
+
error="invalid_client_metadata",
|
| 61 |
+
error_description="token_endpoint_auth_method 'private_key_jwt' is not supported",
|
| 62 |
+
),
|
| 63 |
+
status_code=400,
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
client_secret = None
|
| 67 |
+
if client_metadata.token_endpoint_auth_method != "none": # pragma: no branch
|
| 68 |
+
# cryptographically secure random 32-byte hex string
|
| 69 |
+
client_secret = secrets.token_hex(32)
|
| 70 |
+
|
| 71 |
+
if client_metadata.scope is None and self.options.default_scopes is not None:
|
| 72 |
+
client_metadata.scope = " ".join(self.options.default_scopes)
|
| 73 |
+
elif client_metadata.scope is not None and self.options.valid_scopes is not None:
|
| 74 |
+
requested_scopes = set(client_metadata.scope.split())
|
| 75 |
+
valid_scopes = set(self.options.valid_scopes)
|
| 76 |
+
if not requested_scopes.issubset(valid_scopes): # pragma: no branch
|
| 77 |
+
return PydanticJSONResponse(
|
| 78 |
+
content=RegistrationErrorResponse(
|
| 79 |
+
error="invalid_client_metadata",
|
| 80 |
+
error_description="Requested scopes are not valid: "
|
| 81 |
+
f"{', '.join(requested_scopes - valid_scopes)}",
|
| 82 |
+
),
|
| 83 |
+
status_code=400,
|
| 84 |
+
)
|
| 85 |
+
if "authorization_code" not in client_metadata.grant_types:
|
| 86 |
+
return PydanticJSONResponse(
|
| 87 |
+
content=RegistrationErrorResponse(
|
| 88 |
+
error="invalid_client_metadata",
|
| 89 |
+
error_description="grant_types must include 'authorization_code'",
|
| 90 |
+
),
|
| 91 |
+
status_code=400,
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
# SEP-990 §5.1 / draft-ietf-oauth-identity-assertion-authz-grant §8.1: the ID-JAG flow is
|
| 95 |
+
# for confidential clients provisioned out of band. Refuse to grant it through DCR so a
|
| 96 |
+
# self-registered client cannot reach the identity-assertion provider hook.
|
| 97 |
+
if JWT_BEARER_GRANT_TYPE in client_metadata.grant_types:
|
| 98 |
+
return PydanticJSONResponse(
|
| 99 |
+
content=RegistrationErrorResponse(
|
| 100 |
+
error="invalid_client_metadata",
|
| 101 |
+
error_description=(
|
| 102 |
+
f"grant_types must not include '{JWT_BEARER_GRANT_TYPE}'; "
|
| 103 |
+
"the identity-assertion grant requires a pre-registered client"
|
| 104 |
+
),
|
| 105 |
+
),
|
| 106 |
+
status_code=400,
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
# The MCP spec requires servers to use the authorization `code` flow
|
| 110 |
+
# with PKCE
|
| 111 |
+
if "code" not in client_metadata.response_types:
|
| 112 |
+
return PydanticJSONResponse(
|
| 113 |
+
content=RegistrationErrorResponse(
|
| 114 |
+
error="invalid_client_metadata",
|
| 115 |
+
error_description="response_types must include 'code' for authorization_code grant",
|
| 116 |
+
),
|
| 117 |
+
status_code=400,
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
client_id_issued_at = int(time.time())
|
| 121 |
+
# RFC 7591 §3.2.1: client_secret_expires_at is REQUIRED whenever a client_secret is
|
| 122 |
+
# issued, with 0 (not omission) meaning it never expires; a public client gets none.
|
| 123 |
+
client_secret_expires_at = None
|
| 124 |
+
if client_secret is not None:
|
| 125 |
+
client_secret_expires_at = (
|
| 126 |
+
client_id_issued_at + self.options.client_secret_expiry_seconds
|
| 127 |
+
if self.options.client_secret_expiry_seconds is not None
|
| 128 |
+
else 0
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
# RFC 7591 §3.2.1: the response returns all registered metadata about the client, so
|
| 132 |
+
# the record is the whole validated request plus the credentials minted here - built
|
| 133 |
+
# from the request's dump so no metadata field can be silently omitted from the echo.
|
| 134 |
+
client_info = OAuthClientInformationFull.model_validate(
|
| 135 |
+
{
|
| 136 |
+
**client_metadata.model_dump(),
|
| 137 |
+
"client_id": client_id,
|
| 138 |
+
"client_id_issued_at": client_id_issued_at,
|
| 139 |
+
"client_secret": client_secret,
|
| 140 |
+
"client_secret_expires_at": client_secret_expires_at,
|
| 141 |
+
}
|
| 142 |
+
)
|
| 143 |
+
try:
|
| 144 |
+
# Register client
|
| 145 |
+
await self.provider.register_client(client_info)
|
| 146 |
+
|
| 147 |
+
# Return client information
|
| 148 |
+
return PydanticJSONResponse(content=client_info, status_code=201)
|
| 149 |
+
except RegistrationError as e:
|
| 150 |
+
# Handle registration errors as defined in RFC 7591 Section 3.2.2
|
| 151 |
+
return PydanticJSONResponse(
|
| 152 |
+
content=RegistrationErrorResponse(error=e.error, error_description=e.error_description),
|
| 153 |
+
status_code=400,
|
| 154 |
+
)
|
.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/revoke.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass
|
| 2 |
+
from functools import partial
|
| 3 |
+
from typing import Any, Literal
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel, ValidationError
|
| 6 |
+
from starlette.requests import Request
|
| 7 |
+
from starlette.responses import Response
|
| 8 |
+
|
| 9 |
+
from mcp.server.auth.errors import (
|
| 10 |
+
stringify_pydantic_error,
|
| 11 |
+
)
|
| 12 |
+
from mcp.server.auth.json_response import PydanticJSONResponse
|
| 13 |
+
from mcp.server.auth.middleware.client_auth import AuthenticationError, ClientAuthenticator
|
| 14 |
+
from mcp.server.auth.provider import AccessToken, OAuthAuthorizationServerProvider, RefreshToken
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class RevocationRequest(BaseModel):
|
| 18 |
+
"""See https://datatracker.ietf.org/doc/html/rfc7009#section-2.1"""
|
| 19 |
+
|
| 20 |
+
token: str
|
| 21 |
+
token_type_hint: Literal["access_token", "refresh_token"] | None = None
|
| 22 |
+
client_id: str
|
| 23 |
+
client_secret: str | None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class RevocationErrorResponse(BaseModel):
|
| 27 |
+
error: Literal["invalid_request", "unauthorized_client"]
|
| 28 |
+
error_description: str | None = None
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass
|
| 32 |
+
class RevocationHandler:
|
| 33 |
+
provider: OAuthAuthorizationServerProvider[Any, Any, Any]
|
| 34 |
+
client_authenticator: ClientAuthenticator
|
| 35 |
+
|
| 36 |
+
async def handle(self, request: Request) -> Response:
|
| 37 |
+
"""Handler for the OAuth 2.0 Token Revocation endpoint."""
|
| 38 |
+
try:
|
| 39 |
+
client = await self.client_authenticator.authenticate_request(request)
|
| 40 |
+
except AuthenticationError as e: # pragma: no cover
|
| 41 |
+
return PydanticJSONResponse(
|
| 42 |
+
status_code=401,
|
| 43 |
+
content=RevocationErrorResponse(
|
| 44 |
+
error="unauthorized_client",
|
| 45 |
+
error_description=e.message,
|
| 46 |
+
),
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
form_data = await request.form()
|
| 51 |
+
revocation_request = RevocationRequest.model_validate(dict(form_data))
|
| 52 |
+
except ValidationError as e:
|
| 53 |
+
return PydanticJSONResponse(
|
| 54 |
+
status_code=400,
|
| 55 |
+
content=RevocationErrorResponse(
|
| 56 |
+
error="invalid_request",
|
| 57 |
+
error_description=stringify_pydantic_error(e),
|
| 58 |
+
),
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
loaders = [
|
| 62 |
+
self.provider.load_access_token,
|
| 63 |
+
partial(self.provider.load_refresh_token, client),
|
| 64 |
+
]
|
| 65 |
+
if revocation_request.token_type_hint == "refresh_token": # pragma: no cover
|
| 66 |
+
loaders = reversed(loaders)
|
| 67 |
+
|
| 68 |
+
token: None | AccessToken | RefreshToken = None
|
| 69 |
+
for loader in loaders:
|
| 70 |
+
token = await loader(revocation_request.token)
|
| 71 |
+
if token is not None:
|
| 72 |
+
break
|
| 73 |
+
|
| 74 |
+
# if token is not found, just return HTTP 200 per the RFC
|
| 75 |
+
if token and token.client_id == client.client_id:
|
| 76 |
+
# Revoke token; provider is not meant to be able to do validation
|
| 77 |
+
# at this point that would result in an error
|
| 78 |
+
await self.provider.revoke_token(token)
|
| 79 |
+
|
| 80 |
+
# Return successful empty response
|
| 81 |
+
return Response(
|
| 82 |
+
status_code=200,
|
| 83 |
+
headers={
|
| 84 |
+
"Cache-Control": "no-store",
|
| 85 |
+
"Pragma": "no-cache",
|
| 86 |
+
},
|
| 87 |
+
)
|
.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/token.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
import hashlib
|
| 3 |
+
import time
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from typing import Annotated, Any, Literal
|
| 6 |
+
|
| 7 |
+
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, TypeAdapter, ValidationError
|
| 8 |
+
from starlette.requests import Request
|
| 9 |
+
|
| 10 |
+
from mcp.server.auth.errors import stringify_pydantic_error
|
| 11 |
+
from mcp.server.auth.json_response import PydanticJSONResponse
|
| 12 |
+
from mcp.server.auth.middleware.client_auth import AuthenticationError, ClientAuthenticator
|
| 13 |
+
from mcp.server.auth.provider import (
|
| 14 |
+
IdentityAssertionParams,
|
| 15 |
+
OAuthAuthorizationServerProvider,
|
| 16 |
+
TokenError,
|
| 17 |
+
TokenErrorCode,
|
| 18 |
+
)
|
| 19 |
+
from mcp.shared.auth import OAuthToken
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class AuthorizationCodeRequest(BaseModel):
|
| 23 |
+
# See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3
|
| 24 |
+
grant_type: Literal["authorization_code"]
|
| 25 |
+
code: str = Field(..., description="The authorization code")
|
| 26 |
+
redirect_uri: AnyUrl | None = Field(None, description="Must be the same as redirect URI provided in /authorize")
|
| 27 |
+
client_id: str
|
| 28 |
+
# we use the client_secret param, per https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1
|
| 29 |
+
client_secret: str | None = None
|
| 30 |
+
# See https://datatracker.ietf.org/doc/html/rfc7636#section-4.5
|
| 31 |
+
code_verifier: str = Field(..., description="PKCE code verifier")
|
| 32 |
+
# RFC 8707 resource indicator
|
| 33 |
+
resource: str | None = Field(None, description="Resource indicator for the token")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class RefreshTokenRequest(BaseModel):
|
| 37 |
+
# See https://datatracker.ietf.org/doc/html/rfc6749#section-6
|
| 38 |
+
grant_type: Literal["refresh_token"]
|
| 39 |
+
refresh_token: str = Field(..., description="The refresh token")
|
| 40 |
+
scope: str | None = Field(None, description="Optional scope parameter")
|
| 41 |
+
client_id: str
|
| 42 |
+
# we use the client_secret param, per https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1
|
| 43 |
+
client_secret: str | None = None
|
| 44 |
+
# RFC 8707 resource indicator
|
| 45 |
+
resource: str | None = Field(None, description="Resource indicator for the token")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class JwtBearerRequest(BaseModel):
|
| 49 |
+
# RFC 7523 §2.1 JWT bearer authorization grant. SEP-990 leg 2: the client presents the
|
| 50 |
+
# enterprise IdP-issued ID-JAG to the MCP authorization server as the `assertion`.
|
| 51 |
+
grant_type: Literal["urn:ietf:params:oauth:grant-type:jwt-bearer"]
|
| 52 |
+
# See https://datatracker.ietf.org/doc/html/rfc7523#section-2.1
|
| 53 |
+
assertion: str = Field(..., description="The ID-JAG (a signed JWT) being presented as the grant")
|
| 54 |
+
scope: str | None = Field(None, description="Optional scope parameter")
|
| 55 |
+
client_id: str
|
| 56 |
+
# we use the client_secret param, per https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1
|
| 57 |
+
client_secret: str | None = None
|
| 58 |
+
# RFC 8707 resource indicator
|
| 59 |
+
resource: str | None = Field(None, description="Resource indicator for the token")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
TokenRequest = Annotated[
|
| 63 |
+
AuthorizationCodeRequest | RefreshTokenRequest | JwtBearerRequest,
|
| 64 |
+
Field(discriminator="grant_type"),
|
| 65 |
+
]
|
| 66 |
+
token_request_adapter = TypeAdapter[TokenRequest](TokenRequest)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class TokenErrorResponse(BaseModel):
|
| 70 |
+
"""See https://datatracker.ietf.org/doc/html/rfc6749#section-5.2"""
|
| 71 |
+
|
| 72 |
+
error: TokenErrorCode
|
| 73 |
+
error_description: str | None = None
|
| 74 |
+
error_uri: AnyHttpUrl | None = None
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# this is just an alias over OAuthToken; the only reason we do this
|
| 78 |
+
# is to have some separation between the HTTP response type, and the
|
| 79 |
+
# type returned by the provider
|
| 80 |
+
TokenSuccessResponse = OAuthToken
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@dataclass
|
| 84 |
+
class TokenHandler:
|
| 85 |
+
provider: OAuthAuthorizationServerProvider[Any, Any, Any]
|
| 86 |
+
client_authenticator: ClientAuthenticator
|
| 87 |
+
identity_assertion_enabled: bool = False
|
| 88 |
+
|
| 89 |
+
def response(self, obj: TokenSuccessResponse | TokenErrorResponse):
|
| 90 |
+
status_code = 200
|
| 91 |
+
if isinstance(obj, TokenErrorResponse):
|
| 92 |
+
status_code = 400
|
| 93 |
+
|
| 94 |
+
return PydanticJSONResponse(
|
| 95 |
+
content=obj,
|
| 96 |
+
status_code=status_code,
|
| 97 |
+
headers={
|
| 98 |
+
"Cache-Control": "no-store",
|
| 99 |
+
"Pragma": "no-cache",
|
| 100 |
+
},
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
async def handle(self, request: Request):
|
| 104 |
+
try:
|
| 105 |
+
client_info = await self.client_authenticator.authenticate_request(request)
|
| 106 |
+
except AuthenticationError as e:
|
| 107 |
+
# Authentication failures should return 401
|
| 108 |
+
return PydanticJSONResponse(
|
| 109 |
+
content=TokenErrorResponse(
|
| 110 |
+
error="invalid_client",
|
| 111 |
+
error_description=e.message,
|
| 112 |
+
),
|
| 113 |
+
status_code=401,
|
| 114 |
+
headers={
|
| 115 |
+
"Cache-Control": "no-store",
|
| 116 |
+
"Pragma": "no-cache",
|
| 117 |
+
},
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
try:
|
| 121 |
+
form_data = await request.form()
|
| 122 |
+
# TODO(Marcelo): Can someone check if this `dict()` wrapper is necessary?
|
| 123 |
+
token_request = token_request_adapter.validate_python(dict(form_data))
|
| 124 |
+
except ValidationError as validation_error:
|
| 125 |
+
return self.response(
|
| 126 |
+
TokenErrorResponse(
|
| 127 |
+
error="invalid_request",
|
| 128 |
+
error_description=stringify_pydantic_error(validation_error),
|
| 129 |
+
)
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
if token_request.grant_type not in client_info.grant_types:
|
| 133 |
+
return self.response(
|
| 134 |
+
TokenErrorResponse(
|
| 135 |
+
error="unsupported_grant_type",
|
| 136 |
+
error_description=(f"Unsupported grant type (supported grant types are {client_info.grant_types})"),
|
| 137 |
+
)
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
tokens: OAuthToken
|
| 141 |
+
|
| 142 |
+
match token_request:
|
| 143 |
+
case AuthorizationCodeRequest():
|
| 144 |
+
auth_code = await self.provider.load_authorization_code(client_info, token_request.code)
|
| 145 |
+
if auth_code is None or auth_code.client_id != token_request.client_id:
|
| 146 |
+
# if code belongs to different client, pretend it doesn't exist
|
| 147 |
+
return self.response(
|
| 148 |
+
TokenErrorResponse(
|
| 149 |
+
error="invalid_grant",
|
| 150 |
+
error_description="authorization code does not exist",
|
| 151 |
+
)
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
# make auth codes expire after a deadline
|
| 155 |
+
# see https://datatracker.ietf.org/doc/html/rfc6749#section-10.5
|
| 156 |
+
if auth_code.expires_at < time.time():
|
| 157 |
+
return self.response(
|
| 158 |
+
TokenErrorResponse(
|
| 159 |
+
error="invalid_grant",
|
| 160 |
+
error_description="authorization code has expired",
|
| 161 |
+
)
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
# verify redirect_uri doesn't change between /authorize and /tokens
|
| 165 |
+
# see https://datatracker.ietf.org/doc/html/rfc6749#section-10.6
|
| 166 |
+
if auth_code.redirect_uri_provided_explicitly:
|
| 167 |
+
authorize_request_redirect_uri = auth_code.redirect_uri
|
| 168 |
+
else: # pragma: no cover
|
| 169 |
+
authorize_request_redirect_uri = None
|
| 170 |
+
|
| 171 |
+
# Convert both sides to strings for comparison to handle AnyUrl vs string issues
|
| 172 |
+
token_redirect_str = str(token_request.redirect_uri) if token_request.redirect_uri is not None else None
|
| 173 |
+
auth_redirect_str = (
|
| 174 |
+
str(authorize_request_redirect_uri) if authorize_request_redirect_uri is not None else None
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
if token_redirect_str != auth_redirect_str:
|
| 178 |
+
return self.response(
|
| 179 |
+
TokenErrorResponse(
|
| 180 |
+
error="invalid_request",
|
| 181 |
+
error_description=("redirect_uri did not match the one used when creating auth code"),
|
| 182 |
+
)
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
# Verify PKCE code verifier
|
| 186 |
+
sha256 = hashlib.sha256(token_request.code_verifier.encode()).digest()
|
| 187 |
+
hashed_code_verifier = base64.urlsafe_b64encode(sha256).decode().rstrip("=")
|
| 188 |
+
|
| 189 |
+
if hashed_code_verifier != auth_code.code_challenge:
|
| 190 |
+
# see https://datatracker.ietf.org/doc/html/rfc7636#section-4.6
|
| 191 |
+
return self.response(
|
| 192 |
+
TokenErrorResponse(
|
| 193 |
+
error="invalid_grant",
|
| 194 |
+
error_description="incorrect code_verifier",
|
| 195 |
+
)
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
try:
|
| 199 |
+
# Exchange authorization code for tokens
|
| 200 |
+
tokens = await self.provider.exchange_authorization_code(client_info, auth_code)
|
| 201 |
+
except TokenError as e:
|
| 202 |
+
return self.response(TokenErrorResponse(error=e.error, error_description=e.error_description))
|
| 203 |
+
|
| 204 |
+
case RefreshTokenRequest():
|
| 205 |
+
refresh_token = await self.provider.load_refresh_token(client_info, token_request.refresh_token)
|
| 206 |
+
if refresh_token is None or refresh_token.client_id != token_request.client_id:
|
| 207 |
+
# if token belongs to different client, pretend it doesn't exist
|
| 208 |
+
return self.response(
|
| 209 |
+
TokenErrorResponse(
|
| 210 |
+
error="invalid_grant",
|
| 211 |
+
error_description="refresh token does not exist",
|
| 212 |
+
)
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
if refresh_token.expires_at and refresh_token.expires_at < time.time():
|
| 216 |
+
# if the refresh token has expired, pretend it doesn't exist
|
| 217 |
+
return self.response(
|
| 218 |
+
TokenErrorResponse(
|
| 219 |
+
error="invalid_grant",
|
| 220 |
+
error_description="refresh token has expired",
|
| 221 |
+
)
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
# Parse scopes if provided
|
| 225 |
+
scopes = token_request.scope.split(" ") if token_request.scope else refresh_token.scopes
|
| 226 |
+
|
| 227 |
+
for scope in scopes:
|
| 228 |
+
if scope not in refresh_token.scopes:
|
| 229 |
+
return self.response(
|
| 230 |
+
TokenErrorResponse(
|
| 231 |
+
error="invalid_scope",
|
| 232 |
+
error_description=(f"cannot request scope `{scope}` not provided by refresh token"),
|
| 233 |
+
)
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
try:
|
| 237 |
+
# Exchange refresh token for new tokens
|
| 238 |
+
tokens = await self.provider.exchange_refresh_token(client_info, refresh_token, scopes)
|
| 239 |
+
except TokenError as e:
|
| 240 |
+
return self.response(TokenErrorResponse(error=e.error, error_description=e.error_description))
|
| 241 |
+
|
| 242 |
+
case JwtBearerRequest(): # pragma: no branch
|
| 243 |
+
if not self.identity_assertion_enabled:
|
| 244 |
+
return self.response(
|
| 245 |
+
TokenErrorResponse(
|
| 246 |
+
error="unsupported_grant_type",
|
| 247 |
+
error_description="The JWT bearer grant is not supported by this authorization server",
|
| 248 |
+
)
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
# SEP-990 §5.1: only confidential clients may present an ID-JAG. ClientAuthenticator
|
| 252 |
+
# already rejects a secret-based method with no stored secret; this additionally
|
| 253 |
+
# rejects the public `none` method so an unauthenticated client never reaches the
|
| 254 |
+
# provider hook.
|
| 255 |
+
if not client_info.client_secret:
|
| 256 |
+
# RFC 6749 §5.2: the client authenticated but is not permitted this grant, so
|
| 257 |
+
# unauthorized_client (not invalid_client, which is for failed authentication).
|
| 258 |
+
return self.response(
|
| 259 |
+
TokenErrorResponse(
|
| 260 |
+
error="unauthorized_client",
|
| 261 |
+
error_description="The JWT bearer grant requires a confidential client",
|
| 262 |
+
)
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
params = IdentityAssertionParams(
|
| 266 |
+
assertion=token_request.assertion,
|
| 267 |
+
scopes=token_request.scope.split(" ") if token_request.scope else None,
|
| 268 |
+
resource=token_request.resource,
|
| 269 |
+
)
|
| 270 |
+
try:
|
| 271 |
+
tokens = await self.provider.exchange_identity_assertion(client_info, params)
|
| 272 |
+
except TokenError as e:
|
| 273 |
+
return self.response(TokenErrorResponse(error=e.error, error_description=e.error_description))
|
| 274 |
+
|
| 275 |
+
return self.response(tokens)
|
.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/json_response.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
|
| 3 |
+
from starlette.responses import JSONResponse
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class PydanticJSONResponse(JSONResponse):
|
| 7 |
+
# use pydantic json serialization instead of the stock `json.dumps`,
|
| 8 |
+
# so that we can handle serializing pydantic models like AnyHttpUrl
|
| 9 |
+
def render(self, content: Any) -> bytes:
|
| 10 |
+
return content.model_dump_json(exclude_none=True).encode("utf-8")
|
.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/middleware/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Middleware for MCP authorization."""
|
.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/middleware/auth_context.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import contextvars
|
| 2 |
+
|
| 3 |
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
| 4 |
+
|
| 5 |
+
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
|
| 6 |
+
from mcp.server.auth.provider import AccessToken
|
| 7 |
+
|
| 8 |
+
# Create a contextvar to store the authenticated user
|
| 9 |
+
# The default is None, indicating no authenticated user is present
|
| 10 |
+
auth_context_var = contextvars.ContextVar[AuthenticatedUser | None]("auth_context", default=None)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def get_access_token() -> AccessToken | None:
|
| 14 |
+
"""Get the access token from the current context.
|
| 15 |
+
|
| 16 |
+
Returns:
|
| 17 |
+
The access token if an authenticated user is available, None otherwise.
|
| 18 |
+
"""
|
| 19 |
+
auth_user = auth_context_var.get()
|
| 20 |
+
return auth_user.access_token if auth_user else None
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class AuthContextMiddleware:
|
| 24 |
+
"""Middleware that extracts the authenticated user from the request
|
| 25 |
+
and sets it in a contextvar for easy access throughout the request lifecycle.
|
| 26 |
+
|
| 27 |
+
This middleware should be added after the AuthenticationMiddleware in the
|
| 28 |
+
middleware stack to ensure that the user is properly authenticated before
|
| 29 |
+
being stored in the context.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
def __init__(self, app: ASGIApp):
|
| 33 |
+
self.app = app
|
| 34 |
+
|
| 35 |
+
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
| 36 |
+
user = scope.get("user")
|
| 37 |
+
if isinstance(user, AuthenticatedUser):
|
| 38 |
+
# Set the authenticated user in the contextvar
|
| 39 |
+
token = auth_context_var.set(user)
|
| 40 |
+
try:
|
| 41 |
+
await self.app(scope, receive, send)
|
| 42 |
+
finally:
|
| 43 |
+
auth_context_var.reset(token)
|
| 44 |
+
else:
|
| 45 |
+
# No authenticated user, just process the request
|
| 46 |
+
await self.app(scope, receive, send)
|
.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/middleware/bearer_auth.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import time
|
| 3 |
+
from typing import Any, TypedDict
|
| 4 |
+
|
| 5 |
+
from pydantic import AnyHttpUrl
|
| 6 |
+
from starlette.authentication import AuthCredentials, AuthenticationBackend, SimpleUser
|
| 7 |
+
from starlette.requests import HTTPConnection
|
| 8 |
+
from starlette.types import Receive, Scope, Send
|
| 9 |
+
|
| 10 |
+
from mcp.server.auth.provider import AccessToken, TokenVerifier, principal_components
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class AuthenticatedUser(SimpleUser):
|
| 14 |
+
"""User with authentication info."""
|
| 15 |
+
|
| 16 |
+
def __init__(self, auth_info: AccessToken):
|
| 17 |
+
super().__init__(auth_info.client_id)
|
| 18 |
+
self.access_token = auth_info
|
| 19 |
+
self.scopes = auth_info.scopes
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class AuthorizationContext(TypedDict):
|
| 23 |
+
client_id: str
|
| 24 |
+
issuer: str | None
|
| 25 |
+
subject: str | None
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def authorization_context(user: AuthenticatedUser) -> AuthorizationContext:
|
| 29 |
+
"""Identify the principal `user` represents, for transports to compare
|
| 30 |
+
against the principal that created a session. Components the token
|
| 31 |
+
verifier does not supply are `None`, so the comparison degrades to the
|
| 32 |
+
remaining components.
|
| 33 |
+
|
| 34 |
+
See `examples/servers/simple-auth/mcp_simple_auth/token_verifier.py` for
|
| 35 |
+
a verifier that populates `subject` and `claims` from an introspection
|
| 36 |
+
response."""
|
| 37 |
+
client_id, issuer, subject = principal_components(user.access_token)
|
| 38 |
+
return AuthorizationContext(client_id=client_id, issuer=issuer, subject=subject)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class BearerAuthBackend(AuthenticationBackend):
|
| 42 |
+
"""Authentication backend that validates Bearer tokens using a TokenVerifier."""
|
| 43 |
+
|
| 44 |
+
def __init__(self, token_verifier: TokenVerifier):
|
| 45 |
+
self.token_verifier = token_verifier
|
| 46 |
+
|
| 47 |
+
async def authenticate(self, conn: HTTPConnection):
|
| 48 |
+
auth_header = next(
|
| 49 |
+
(conn.headers.get(key) for key in conn.headers if key.lower() == "authorization"),
|
| 50 |
+
None,
|
| 51 |
+
)
|
| 52 |
+
if not auth_header or not auth_header.lower().startswith("bearer "):
|
| 53 |
+
return None
|
| 54 |
+
|
| 55 |
+
token = auth_header[7:] # Remove "Bearer " prefix
|
| 56 |
+
|
| 57 |
+
# Validate the token with the verifier
|
| 58 |
+
auth_info = await self.token_verifier.verify_token(token)
|
| 59 |
+
|
| 60 |
+
if not auth_info:
|
| 61 |
+
return None
|
| 62 |
+
|
| 63 |
+
if auth_info.expires_at and auth_info.expires_at < int(time.time()):
|
| 64 |
+
return None
|
| 65 |
+
|
| 66 |
+
return AuthCredentials(auth_info.scopes), AuthenticatedUser(auth_info)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class RequireAuthMiddleware:
|
| 70 |
+
"""Middleware that requires a valid Bearer token in the Authorization header.
|
| 71 |
+
|
| 72 |
+
This will validate the token with the auth provider and store the resulting
|
| 73 |
+
auth info in the request state.
|
| 74 |
+
"""
|
| 75 |
+
|
| 76 |
+
def __init__(
|
| 77 |
+
self,
|
| 78 |
+
app: Any,
|
| 79 |
+
required_scopes: list[str],
|
| 80 |
+
resource_metadata_url: AnyHttpUrl | None = None,
|
| 81 |
+
):
|
| 82 |
+
"""Initialize the middleware.
|
| 83 |
+
|
| 84 |
+
Args:
|
| 85 |
+
app: ASGI application
|
| 86 |
+
required_scopes: List of scopes that the token must have
|
| 87 |
+
resource_metadata_url: Optional protected resource metadata URL for WWW-Authenticate header
|
| 88 |
+
"""
|
| 89 |
+
self.app = app
|
| 90 |
+
self.required_scopes = required_scopes
|
| 91 |
+
self.resource_metadata_url = resource_metadata_url
|
| 92 |
+
|
| 93 |
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
| 94 |
+
auth_user = scope.get("user")
|
| 95 |
+
if not isinstance(auth_user, AuthenticatedUser):
|
| 96 |
+
await self._send_auth_error(
|
| 97 |
+
send, status_code=401, error="invalid_token", description="Authentication required"
|
| 98 |
+
)
|
| 99 |
+
return
|
| 100 |
+
|
| 101 |
+
auth_credentials = scope.get("auth")
|
| 102 |
+
|
| 103 |
+
for required_scope in self.required_scopes:
|
| 104 |
+
# auth_credentials should always be provided; this is just paranoia
|
| 105 |
+
if auth_credentials is None or required_scope not in auth_credentials.scopes:
|
| 106 |
+
await self._send_auth_error(
|
| 107 |
+
send, status_code=403, error="insufficient_scope", description=f"Required scope: {required_scope}"
|
| 108 |
+
)
|
| 109 |
+
return
|
| 110 |
+
|
| 111 |
+
await self.app(scope, receive, send)
|
| 112 |
+
|
| 113 |
+
async def _send_auth_error(self, send: Send, status_code: int, error: str, description: str) -> None:
|
| 114 |
+
"""Send an authentication error response with WWW-Authenticate header."""
|
| 115 |
+
# Build WWW-Authenticate header value
|
| 116 |
+
www_auth_parts = [f'error="{error}"', f'error_description="{description}"']
|
| 117 |
+
if self.resource_metadata_url:
|
| 118 |
+
www_auth_parts.append(f'resource_metadata="{self.resource_metadata_url}"')
|
| 119 |
+
|
| 120 |
+
www_authenticate = f"Bearer {', '.join(www_auth_parts)}"
|
| 121 |
+
|
| 122 |
+
# Send response
|
| 123 |
+
body = {"error": error, "error_description": description}
|
| 124 |
+
body_bytes = json.dumps(body).encode()
|
| 125 |
+
|
| 126 |
+
await send(
|
| 127 |
+
{
|
| 128 |
+
"type": "http.response.start",
|
| 129 |
+
"status": status_code,
|
| 130 |
+
"headers": [
|
| 131 |
+
(b"content-type", b"application/json"),
|
| 132 |
+
(b"content-length", str(len(body_bytes)).encode()),
|
| 133 |
+
(b"www-authenticate", www_authenticate.encode()),
|
| 134 |
+
],
|
| 135 |
+
}
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
await send(
|
| 139 |
+
{
|
| 140 |
+
"type": "http.response.body",
|
| 141 |
+
"body": body_bytes,
|
| 142 |
+
}
|
| 143 |
+
)
|
.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/middleware/client_auth.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
import binascii
|
| 3 |
+
import hmac
|
| 4 |
+
import time
|
| 5 |
+
from typing import Any
|
| 6 |
+
from urllib.parse import unquote
|
| 7 |
+
|
| 8 |
+
from starlette.requests import Request
|
| 9 |
+
|
| 10 |
+
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
|
| 11 |
+
from mcp.shared.auth import OAuthClientInformationFull
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class AuthenticationError(Exception):
|
| 15 |
+
def __init__(self, message: str):
|
| 16 |
+
self.message = message
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class ClientAuthenticator:
|
| 20 |
+
"""ClientAuthenticator is a callable which validates requests from a client
|
| 21 |
+
application, used to verify /token calls.
|
| 22 |
+
|
| 23 |
+
If, during registration, the client requested to be issued a secret, the
|
| 24 |
+
authenticator asserts that /token calls must be authenticated with
|
| 25 |
+
that same secret.
|
| 26 |
+
|
| 27 |
+
NOTE: clients can opt for no authentication during registration, in which case this
|
| 28 |
+
logic is skipped.
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
def __init__(self, provider: OAuthAuthorizationServerProvider[Any, Any, Any]):
|
| 32 |
+
"""Initialize the authenticator.
|
| 33 |
+
|
| 34 |
+
Args:
|
| 35 |
+
provider: Provider to look up client information
|
| 36 |
+
"""
|
| 37 |
+
self.provider = provider
|
| 38 |
+
|
| 39 |
+
async def authenticate_request(self, request: Request) -> OAuthClientInformationFull:
|
| 40 |
+
"""Authenticate a client from an HTTP request.
|
| 41 |
+
|
| 42 |
+
Extracts client credentials from the appropriate location based on the
|
| 43 |
+
client's registered authentication method and validates them.
|
| 44 |
+
|
| 45 |
+
Args:
|
| 46 |
+
request: The HTTP request containing client credentials
|
| 47 |
+
|
| 48 |
+
Returns:
|
| 49 |
+
The authenticated client information
|
| 50 |
+
|
| 51 |
+
Raises:
|
| 52 |
+
AuthenticationError: If authentication fails
|
| 53 |
+
"""
|
| 54 |
+
form_data = await request.form()
|
| 55 |
+
client_id = form_data.get("client_id")
|
| 56 |
+
if not client_id:
|
| 57 |
+
raise AuthenticationError("Missing client_id")
|
| 58 |
+
|
| 59 |
+
client = await self.provider.get_client(str(client_id))
|
| 60 |
+
if not client:
|
| 61 |
+
raise AuthenticationError("Invalid client_id") # pragma: no cover
|
| 62 |
+
|
| 63 |
+
request_client_secret: str | None = None
|
| 64 |
+
auth_header = request.headers.get("Authorization", "")
|
| 65 |
+
|
| 66 |
+
if client.token_endpoint_auth_method == "client_secret_basic":
|
| 67 |
+
if not auth_header.startswith("Basic "):
|
| 68 |
+
raise AuthenticationError("Missing or invalid Basic authentication in Authorization header")
|
| 69 |
+
|
| 70 |
+
try:
|
| 71 |
+
encoded_credentials = auth_header[6:] # Remove "Basic " prefix
|
| 72 |
+
decoded = base64.b64decode(encoded_credentials).decode("utf-8")
|
| 73 |
+
if ":" not in decoded:
|
| 74 |
+
raise ValueError("Invalid Basic auth format")
|
| 75 |
+
basic_client_id, request_client_secret = decoded.split(":", 1)
|
| 76 |
+
|
| 77 |
+
# URL-decode both parts per RFC 6749 Section 2.3.1
|
| 78 |
+
basic_client_id = unquote(basic_client_id)
|
| 79 |
+
request_client_secret = unquote(request_client_secret)
|
| 80 |
+
|
| 81 |
+
if basic_client_id != client_id:
|
| 82 |
+
raise AuthenticationError("Client ID mismatch in Basic auth")
|
| 83 |
+
except (ValueError, UnicodeDecodeError, binascii.Error):
|
| 84 |
+
raise AuthenticationError("Invalid Basic authentication header")
|
| 85 |
+
|
| 86 |
+
elif client.token_endpoint_auth_method == "client_secret_post":
|
| 87 |
+
raw_form_data = form_data.get("client_secret")
|
| 88 |
+
# form_data.get() can return an UploadFile or None, so we need to check if it's a string
|
| 89 |
+
if isinstance(raw_form_data, str):
|
| 90 |
+
request_client_secret = str(raw_form_data)
|
| 91 |
+
|
| 92 |
+
elif client.token_endpoint_auth_method == "none":
|
| 93 |
+
request_client_secret = None
|
| 94 |
+
else:
|
| 95 |
+
raise AuthenticationError( # pragma: no cover
|
| 96 |
+
f"Unsupported auth method: {client.token_endpoint_auth_method}"
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
# A client registered for a secret-based auth method but with no stored secret is
|
| 100 |
+
# misconfigured: nothing was actually verified above, so it must not pass authentication.
|
| 101 |
+
if client.token_endpoint_auth_method != "none" and not client.client_secret:
|
| 102 |
+
raise AuthenticationError("Client is registered for secret-based authentication but has no stored secret")
|
| 103 |
+
|
| 104 |
+
# If client from the store expects a secret, validate that the request provides
|
| 105 |
+
# that secret
|
| 106 |
+
if client.client_secret:
|
| 107 |
+
if not request_client_secret:
|
| 108 |
+
raise AuthenticationError("Client secret is required")
|
| 109 |
+
|
| 110 |
+
# hmac.compare_digest requires that both arguments are either bytes or a `str` containing
|
| 111 |
+
# only ASCII characters. Since we do not control `request_client_secret`, we encode both
|
| 112 |
+
# arguments to bytes.
|
| 113 |
+
if not hmac.compare_digest(client.client_secret.encode(), request_client_secret.encode()):
|
| 114 |
+
raise AuthenticationError("Invalid client_secret")
|
| 115 |
+
|
| 116 |
+
if client.client_secret_expires_at and client.client_secret_expires_at < int(time.time()):
|
| 117 |
+
raise AuthenticationError("Client secret has expired") # pragma: no cover
|
| 118 |
+
|
| 119 |
+
return client
|
.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/provider.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass
|
| 2 |
+
from typing import Any, Generic, Literal, Protocol, TypeVar
|
| 3 |
+
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
| 4 |
+
|
| 5 |
+
from pydantic import AnyUrl, BaseModel
|
| 6 |
+
|
| 7 |
+
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class AuthorizationParams(BaseModel):
|
| 11 |
+
state: str | None
|
| 12 |
+
scopes: list[str] | None
|
| 13 |
+
code_challenge: str
|
| 14 |
+
redirect_uri: AnyUrl
|
| 15 |
+
redirect_uri_provided_explicitly: bool
|
| 16 |
+
resource: str | None = None # RFC 8707 resource indicator
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class IdentityAssertionParams(BaseModel):
|
| 20 |
+
"""Validated parameters of a SEP-990 identity-assertion (RFC 7523 jwt-bearer) request.
|
| 21 |
+
|
| 22 |
+
Passed to ``OAuthAuthorizationServerProvider.exchange_identity_assertion``. ``assertion`` is the
|
| 23 |
+
ID-JAG (a signed JWT) the enterprise identity provider issued; the provider validates it per
|
| 24 |
+
RFC 7523 §3 and the SEP-990 §5.1 processing rules before issuing an access token.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
assertion: str # RFC 7523 §2.1: the JWT (ID-JAG) presented as the authorization grant
|
| 28 |
+
scopes: list[str] | None = None
|
| 29 |
+
resource: str | None = None # RFC 8707 resource indicator from the token request
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class AuthorizationCode(BaseModel):
|
| 33 |
+
code: str
|
| 34 |
+
scopes: list[str]
|
| 35 |
+
expires_at: float
|
| 36 |
+
client_id: str
|
| 37 |
+
code_challenge: str
|
| 38 |
+
redirect_uri: AnyUrl
|
| 39 |
+
redirect_uri_provided_explicitly: bool
|
| 40 |
+
resource: str | None = None # RFC 8707 resource indicator
|
| 41 |
+
subject: str | None = None # resource owner; propagate to the issued AccessToken
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class RefreshToken(BaseModel):
|
| 45 |
+
token: str
|
| 46 |
+
client_id: str
|
| 47 |
+
scopes: list[str]
|
| 48 |
+
expires_at: int | None = None
|
| 49 |
+
subject: str | None = None # resource owner; propagate to refreshed AccessTokens
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class AccessToken(BaseModel):
|
| 53 |
+
token: str
|
| 54 |
+
client_id: str
|
| 55 |
+
scopes: list[str]
|
| 56 |
+
expires_at: int | None = None
|
| 57 |
+
resource: str | None = None # RFC 8707 resource indicator
|
| 58 |
+
subject: str | None = None # RFC 7662/9068 `sub`: resource owner; unique only per issuer
|
| 59 |
+
claims: dict[str, Any] | None = None # additional claims (e.g. `iss`, `act`)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def principal_components(token: AccessToken) -> tuple[str, str | None, str | None]:
|
| 63 |
+
"""The (client_id, issuer, subject) triple identifying the principal a token represents.
|
| 64 |
+
|
| 65 |
+
The single source for "who is this token's principal": session ownership and
|
| 66 |
+
request-state binding both build on it. Components the token verifier does
|
| 67 |
+
not supply are `None`, so comparisons degrade to the remaining components.
|
| 68 |
+
"""
|
| 69 |
+
issuer = (token.claims or {}).get("iss")
|
| 70 |
+
return token.client_id, str(issuer) if issuer is not None else None, token.subject
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
RegistrationErrorCode = Literal[
|
| 74 |
+
"invalid_redirect_uri",
|
| 75 |
+
"invalid_client_metadata",
|
| 76 |
+
"invalid_software_statement",
|
| 77 |
+
"unapproved_software_statement",
|
| 78 |
+
]
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@dataclass(frozen=True)
|
| 82 |
+
class RegistrationError(Exception):
|
| 83 |
+
error: RegistrationErrorCode
|
| 84 |
+
error_description: str | None = None
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
AuthorizationErrorCode = Literal[
|
| 88 |
+
"invalid_request",
|
| 89 |
+
"unauthorized_client",
|
| 90 |
+
"access_denied",
|
| 91 |
+
"unsupported_response_type",
|
| 92 |
+
"invalid_scope",
|
| 93 |
+
"server_error",
|
| 94 |
+
"temporarily_unavailable",
|
| 95 |
+
"invalid_target",
|
| 96 |
+
]
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@dataclass(frozen=True)
|
| 100 |
+
class AuthorizeError(Exception):
|
| 101 |
+
error: AuthorizationErrorCode
|
| 102 |
+
error_description: str | None = None
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
TokenErrorCode = Literal[
|
| 106 |
+
"invalid_request",
|
| 107 |
+
"invalid_client",
|
| 108 |
+
"invalid_grant",
|
| 109 |
+
"unauthorized_client",
|
| 110 |
+
"unsupported_grant_type",
|
| 111 |
+
"invalid_scope",
|
| 112 |
+
# RFC 8707 §2: the requested resource (RFC 8707 indicator) is unknown or unsupported.
|
| 113 |
+
"invalid_target",
|
| 114 |
+
]
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@dataclass(frozen=True)
|
| 118 |
+
class TokenError(Exception):
|
| 119 |
+
error: TokenErrorCode
|
| 120 |
+
error_description: str | None = None
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class TokenVerifier(Protocol):
|
| 124 |
+
"""Protocol for verifying bearer tokens."""
|
| 125 |
+
|
| 126 |
+
async def verify_token(self, token: str) -> AccessToken | None:
|
| 127 |
+
"""Verify a bearer token and return access info if valid."""
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# NOTE: MCPServer doesn't render any of these types in the user response, so it's
|
| 131 |
+
# OK to add fields to subclasses which should not be exposed externally.
|
| 132 |
+
AuthorizationCodeT = TypeVar("AuthorizationCodeT", bound=AuthorizationCode)
|
| 133 |
+
RefreshTokenT = TypeVar("RefreshTokenT", bound=RefreshToken)
|
| 134 |
+
AccessTokenT = TypeVar("AccessTokenT", bound=AccessToken)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class OAuthAuthorizationServerProvider(Protocol, Generic[AuthorizationCodeT, RefreshTokenT, AccessTokenT]):
|
| 138 |
+
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
| 139 |
+
"""Retrieves client information by client ID.
|
| 140 |
+
|
| 141 |
+
Implementors MAY raise NotImplementedError if dynamic client registration is
|
| 142 |
+
disabled in ClientRegistrationOptions.
|
| 143 |
+
|
| 144 |
+
Args:
|
| 145 |
+
client_id: The ID of the client to retrieve.
|
| 146 |
+
|
| 147 |
+
Returns:
|
| 148 |
+
The client information, or None if the client does not exist.
|
| 149 |
+
"""
|
| 150 |
+
|
| 151 |
+
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
| 152 |
+
"""Saves client information as part of registering it.
|
| 153 |
+
|
| 154 |
+
Implementors MAY raise NotImplementedError if dynamic client registration is
|
| 155 |
+
disabled in ClientRegistrationOptions.
|
| 156 |
+
|
| 157 |
+
Args:
|
| 158 |
+
client_info: The client metadata to register.
|
| 159 |
+
|
| 160 |
+
Raises:
|
| 161 |
+
RegistrationError: If the client metadata is invalid.
|
| 162 |
+
"""
|
| 163 |
+
|
| 164 |
+
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
|
| 165 |
+
"""Handle the /authorize endpoint and return a URL that the client
|
| 166 |
+
will be redirected to.
|
| 167 |
+
|
| 168 |
+
Many MCP implementations will redirect to a third-party provider to perform
|
| 169 |
+
a second OAuth exchange with that provider. In this sort of setup, the client
|
| 170 |
+
has an OAuth connection with the MCP server, and the MCP server has an OAuth
|
| 171 |
+
connection with the 3rd-party provider. At the end of this flow, the client
|
| 172 |
+
should be redirected to the redirect_uri from params.redirect_uri.
|
| 173 |
+
|
| 174 |
+
+--------+ +------------+ +-------------------+
|
| 175 |
+
| | | | | |
|
| 176 |
+
| Client | --> | MCP Server | --> | 3rd Party OAuth |
|
| 177 |
+
| | | | | Server |
|
| 178 |
+
+--------+ +------------+ +-------------------+
|
| 179 |
+
| ^ |
|
| 180 |
+
+------------+ | | |
|
| 181 |
+
| | | | Redirect |
|
| 182 |
+
|redirect_uri|<-----+ +------------------+
|
| 183 |
+
| |
|
| 184 |
+
+------------+
|
| 185 |
+
|
| 186 |
+
Implementations will need to define another handler on the MCP server's return
|
| 187 |
+
flow to perform the second redirect, and generate and store an authorization
|
| 188 |
+
code as part of completing the OAuth authorization step.
|
| 189 |
+
|
| 190 |
+
Implementations SHOULD generate an authorization code with at least 160 bits of
|
| 191 |
+
entropy,
|
| 192 |
+
and MUST generate an authorization code with at least 128 bits of entropy.
|
| 193 |
+
See https://datatracker.ietf.org/doc/html/rfc6749#section-10.10.
|
| 194 |
+
|
| 195 |
+
Args:
|
| 196 |
+
client: The client requesting authorization.
|
| 197 |
+
params: The parameters of the authorization request.
|
| 198 |
+
|
| 199 |
+
Returns:
|
| 200 |
+
A URL to redirect the client to for authorization.
|
| 201 |
+
|
| 202 |
+
Raises:
|
| 203 |
+
AuthorizeError: If the authorization request is invalid.
|
| 204 |
+
"""
|
| 205 |
+
...
|
| 206 |
+
|
| 207 |
+
async def load_authorization_code(
|
| 208 |
+
self, client: OAuthClientInformationFull, authorization_code: str
|
| 209 |
+
) -> AuthorizationCodeT | None:
|
| 210 |
+
"""Loads an AuthorizationCode by its code.
|
| 211 |
+
|
| 212 |
+
Args:
|
| 213 |
+
client: The client that requested the authorization code.
|
| 214 |
+
authorization_code: The authorization code to get the challenge for.
|
| 215 |
+
|
| 216 |
+
Returns:
|
| 217 |
+
The AuthorizationCode, or None if not found.
|
| 218 |
+
"""
|
| 219 |
+
...
|
| 220 |
+
|
| 221 |
+
async def exchange_authorization_code(
|
| 222 |
+
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCodeT
|
| 223 |
+
) -> OAuthToken:
|
| 224 |
+
"""Exchanges an authorization code for an access token and refresh token.
|
| 225 |
+
|
| 226 |
+
Args:
|
| 227 |
+
client: The client exchanging the authorization code.
|
| 228 |
+
authorization_code: The authorization code to exchange.
|
| 229 |
+
|
| 230 |
+
Returns:
|
| 231 |
+
The OAuth token, containing access and refresh tokens.
|
| 232 |
+
|
| 233 |
+
Raises:
|
| 234 |
+
TokenError: If the request is invalid.
|
| 235 |
+
"""
|
| 236 |
+
...
|
| 237 |
+
|
| 238 |
+
async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshTokenT | None:
|
| 239 |
+
"""Loads a RefreshToken by its token string.
|
| 240 |
+
|
| 241 |
+
Args:
|
| 242 |
+
client: The client that is requesting to load the refresh token.
|
| 243 |
+
refresh_token: The refresh token string to load.
|
| 244 |
+
|
| 245 |
+
Returns:
|
| 246 |
+
The RefreshToken object if found, or None if not found.
|
| 247 |
+
"""
|
| 248 |
+
...
|
| 249 |
+
|
| 250 |
+
async def exchange_refresh_token(
|
| 251 |
+
self,
|
| 252 |
+
client: OAuthClientInformationFull,
|
| 253 |
+
refresh_token: RefreshTokenT,
|
| 254 |
+
scopes: list[str],
|
| 255 |
+
) -> OAuthToken:
|
| 256 |
+
"""Exchanges a refresh token for an access token and refresh token.
|
| 257 |
+
|
| 258 |
+
Implementations SHOULD rotate both the access token and refresh token.
|
| 259 |
+
|
| 260 |
+
Args:
|
| 261 |
+
client: The client exchanging the refresh token.
|
| 262 |
+
refresh_token: The refresh token to exchange.
|
| 263 |
+
scopes: Optional scopes to request with the new access token.
|
| 264 |
+
|
| 265 |
+
Returns:
|
| 266 |
+
The OAuth token, containing access and refresh tokens.
|
| 267 |
+
|
| 268 |
+
Raises:
|
| 269 |
+
TokenError: If the request is invalid.
|
| 270 |
+
"""
|
| 271 |
+
...
|
| 272 |
+
|
| 273 |
+
async def load_access_token(self, token: str) -> AccessTokenT | None:
|
| 274 |
+
"""Loads an access token by its token string.
|
| 275 |
+
|
| 276 |
+
Args:
|
| 277 |
+
token: The access token to verify.
|
| 278 |
+
|
| 279 |
+
Returns:
|
| 280 |
+
The access token, or None if the token is invalid.
|
| 281 |
+
"""
|
| 282 |
+
|
| 283 |
+
async def revoke_token(
|
| 284 |
+
self,
|
| 285 |
+
token: AccessTokenT | RefreshTokenT,
|
| 286 |
+
) -> None:
|
| 287 |
+
"""Revokes an access or refresh token.
|
| 288 |
+
|
| 289 |
+
If the given token is invalid or already revoked, this method should do nothing.
|
| 290 |
+
|
| 291 |
+
Implementations SHOULD revoke both the access token and its corresponding
|
| 292 |
+
refresh token, regardless of which of the access token or refresh token is
|
| 293 |
+
provided.
|
| 294 |
+
|
| 295 |
+
Args:
|
| 296 |
+
token: The token to revoke.
|
| 297 |
+
"""
|
| 298 |
+
|
| 299 |
+
async def exchange_identity_assertion(
|
| 300 |
+
self,
|
| 301 |
+
client: OAuthClientInformationFull,
|
| 302 |
+
params: IdentityAssertionParams,
|
| 303 |
+
) -> OAuthToken:
|
| 304 |
+
"""Exchanges an Identity Assertion Authorization Grant (ID-JAG) for an access token.
|
| 305 |
+
|
| 306 |
+
This is leg 2 of SEP-990: the client presents an ID-JAG - issued by the enterprise
|
| 307 |
+
identity provider - using the RFC 7523 ``urn:ietf:params:oauth:grant-type:jwt-bearer``
|
| 308 |
+
grant, and receives an access token for this MCP server. The default implementation
|
| 309 |
+
rejects every request as an unsupported grant type; override it to enable the grant.
|
| 310 |
+
|
| 311 |
+
The implementation is responsible for validating ``params.assertion`` per RFC 7523 §3
|
| 312 |
+
and the SEP-990 §5.1 processing rules, in particular:
|
| 313 |
+
|
| 314 |
+
- verify the JWT signature, ``iss``, and ``exp``, and that ``typ`` is ``oauth-id-jag+jwt``;
|
| 315 |
+
- require ``aud`` to identify this authorization server (its own issuer);
|
| 316 |
+
- require a ``sub`` (RFC 7523 §3 makes it mandatory) identifying the end user;
|
| 317 |
+
- reject replays - enforce ``exp``, and track ``jti`` for the assertion's lifetime;
|
| 318 |
+
- require the ID-JAG's ``client_id`` claim to match the authenticated ``client`` - do
|
| 319 |
+
NOT derive authorization from ``client.client_id`` alone, which for a confidential
|
| 320 |
+
client is authenticated but for any client is ultimately self-asserted in the request;
|
| 321 |
+
- audience-restrict the issued access token to the resource named in the ID-JAG's
|
| 322 |
+
``resource`` claim, not merely ``params.resource`` (which the client controls);
|
| 323 |
+
- derive the granted scopes from the ID-JAG and policy rather than granting
|
| 324 |
+
``params.scopes`` verbatim.
|
| 325 |
+
|
| 326 |
+
The handler guarantees ``client`` is confidential (it rejects clients without a stored
|
| 327 |
+
secret before calling this hook), but the ID-JAG remains the authoritative grant.
|
| 328 |
+
|
| 329 |
+
Args:
|
| 330 |
+
client: The authenticated client presenting the assertion.
|
| 331 |
+
params: The validated jwt-bearer request parameters (the ID-JAG and indicators).
|
| 332 |
+
|
| 333 |
+
Returns:
|
| 334 |
+
The OAuth token, containing the issued access token. A refresh token SHOULD NOT be
|
| 335 |
+
issued: SEP-990 relies on the IdP to control session lifetime via re-issued ID-JAGs.
|
| 336 |
+
|
| 337 |
+
Raises:
|
| 338 |
+
TokenError: If the assertion or request is invalid. Use ``invalid_grant`` for a
|
| 339 |
+
rejected assertion and ``invalid_target`` for an unknown ``resource``.
|
| 340 |
+
"""
|
| 341 |
+
raise TokenError(
|
| 342 |
+
error="unsupported_grant_type",
|
| 343 |
+
error_description="The JWT bearer grant is not supported by this authorization server",
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def construct_redirect_uri(redirect_uri_base: str, **params: str | None) -> str:
|
| 348 |
+
parsed_uri = urlparse(redirect_uri_base)
|
| 349 |
+
query_params = [(k, v) for k, vs in parse_qs(parsed_uri.query).items() for v in vs]
|
| 350 |
+
for k, v in params.items():
|
| 351 |
+
if v is not None:
|
| 352 |
+
query_params.append((k, v))
|
| 353 |
+
|
| 354 |
+
redirect_uri = urlunparse(parsed_uri._replace(query=urlencode(query_params)))
|
| 355 |
+
return redirect_uri
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
class ProviderTokenVerifier(TokenVerifier):
|
| 359 |
+
"""Token verifier that uses an OAuthAuthorizationServerProvider.
|
| 360 |
+
|
| 361 |
+
This is provided for backwards compatibility with existing auth_server_provider
|
| 362 |
+
configurations. For new implementations using AS/RS separation, consider using
|
| 363 |
+
the TokenVerifier protocol with a dedicated implementation like IntrospectionTokenVerifier.
|
| 364 |
+
"""
|
| 365 |
+
|
| 366 |
+
def __init__(self, provider: "OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]"):
|
| 367 |
+
self.provider = provider
|
| 368 |
+
|
| 369 |
+
async def verify_token(self, token: str) -> AccessToken | None:
|
| 370 |
+
"""Verify token using the provider's load_access_token method."""
|
| 371 |
+
return await self.provider.load_access_token(token)
|
.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/routes.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from collections.abc import Awaitable, Callable
|
| 2 |
+
from typing import Any
|
| 3 |
+
from urllib.parse import urlparse
|
| 4 |
+
|
| 5 |
+
from pydantic import AnyHttpUrl
|
| 6 |
+
from starlette.middleware.cors import CORSMiddleware
|
| 7 |
+
from starlette.requests import Request
|
| 8 |
+
from starlette.responses import Response
|
| 9 |
+
from starlette.routing import Route, request_response # type: ignore
|
| 10 |
+
from starlette.types import ASGIApp
|
| 11 |
+
|
| 12 |
+
from mcp.server.auth.handlers.authorize import AuthorizationHandler
|
| 13 |
+
from mcp.server.auth.handlers.metadata import MetadataHandler, ProtectedResourceMetadataHandler
|
| 14 |
+
from mcp.server.auth.handlers.register import RegistrationHandler
|
| 15 |
+
from mcp.server.auth.handlers.revoke import RevocationHandler
|
| 16 |
+
from mcp.server.auth.handlers.token import TokenHandler
|
| 17 |
+
from mcp.server.auth.middleware.client_auth import ClientAuthenticator
|
| 18 |
+
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
|
| 19 |
+
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
|
| 20 |
+
from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthMetadata, ProtectedResourceMetadata
|
| 21 |
+
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def validate_issuer_url(url: AnyHttpUrl):
|
| 25 |
+
"""Validate that the issuer URL meets OAuth 2.0 requirements.
|
| 26 |
+
|
| 27 |
+
Args:
|
| 28 |
+
url: The issuer URL to validate.
|
| 29 |
+
|
| 30 |
+
Raises:
|
| 31 |
+
ValueError: If the issuer URL is invalid.
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
# RFC 8414 requires HTTPS, but we allow loopback/localhost HTTP for testing
|
| 35 |
+
if url.scheme != "https" and url.host not in ("localhost", "127.0.0.1", "[::1]"):
|
| 36 |
+
raise ValueError("Issuer URL must be HTTPS")
|
| 37 |
+
|
| 38 |
+
# No fragments or query parameters allowed
|
| 39 |
+
if url.fragment:
|
| 40 |
+
raise ValueError("Issuer URL must not have a fragment")
|
| 41 |
+
if url.query:
|
| 42 |
+
raise ValueError("Issuer URL must not have a query string")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
AUTHORIZATION_PATH = "/authorize"
|
| 46 |
+
TOKEN_PATH = "/token"
|
| 47 |
+
REGISTRATION_PATH = "/register"
|
| 48 |
+
REVOCATION_PATH = "/revoke"
|
| 49 |
+
|
| 50 |
+
# SEP-990: leg 2 uses the RFC 7523 jwt-bearer grant; support is advertised as the ID-JAG profile.
|
| 51 |
+
ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def cors_middleware(
|
| 55 |
+
handler: Callable[[Request], Response | Awaitable[Response]],
|
| 56 |
+
allow_methods: list[str],
|
| 57 |
+
) -> ASGIApp:
|
| 58 |
+
cors_app = CORSMiddleware(
|
| 59 |
+
app=request_response(handler),
|
| 60 |
+
allow_origins="*",
|
| 61 |
+
allow_methods=allow_methods,
|
| 62 |
+
allow_headers=[MCP_PROTOCOL_VERSION_HEADER],
|
| 63 |
+
)
|
| 64 |
+
return cors_app
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def create_auth_routes(
|
| 68 |
+
provider: OAuthAuthorizationServerProvider[Any, Any, Any],
|
| 69 |
+
issuer_url: AnyHttpUrl,
|
| 70 |
+
service_documentation_url: AnyHttpUrl | None = None,
|
| 71 |
+
client_registration_options: ClientRegistrationOptions | None = None,
|
| 72 |
+
revocation_options: RevocationOptions | None = None,
|
| 73 |
+
identity_assertion_enabled: bool = False,
|
| 74 |
+
) -> list[Route]:
|
| 75 |
+
validate_issuer_url(issuer_url)
|
| 76 |
+
|
| 77 |
+
client_registration_options = client_registration_options or ClientRegistrationOptions()
|
| 78 |
+
revocation_options = revocation_options or RevocationOptions()
|
| 79 |
+
metadata = build_metadata(
|
| 80 |
+
issuer_url,
|
| 81 |
+
service_documentation_url,
|
| 82 |
+
client_registration_options,
|
| 83 |
+
revocation_options,
|
| 84 |
+
supports_identity_assertion=identity_assertion_enabled,
|
| 85 |
+
)
|
| 86 |
+
client_authenticator = ClientAuthenticator(provider)
|
| 87 |
+
|
| 88 |
+
# Create routes
|
| 89 |
+
# Allow CORS requests for endpoints meant to be hit by the OAuth client
|
| 90 |
+
# (with the client secret). This is intended to support things like MCP Inspector,
|
| 91 |
+
# where the client runs in a web browser.
|
| 92 |
+
routes = [
|
| 93 |
+
Route(
|
| 94 |
+
"/.well-known/oauth-authorization-server",
|
| 95 |
+
endpoint=cors_middleware(
|
| 96 |
+
MetadataHandler(metadata).handle,
|
| 97 |
+
["GET", "OPTIONS"],
|
| 98 |
+
),
|
| 99 |
+
methods=["GET", "OPTIONS"],
|
| 100 |
+
),
|
| 101 |
+
Route(
|
| 102 |
+
AUTHORIZATION_PATH,
|
| 103 |
+
# do not allow CORS for authorization endpoint;
|
| 104 |
+
# clients should just redirect to this
|
| 105 |
+
endpoint=AuthorizationHandler(provider).handle,
|
| 106 |
+
methods=["GET", "POST"],
|
| 107 |
+
),
|
| 108 |
+
Route(
|
| 109 |
+
TOKEN_PATH,
|
| 110 |
+
endpoint=cors_middleware(
|
| 111 |
+
TokenHandler(
|
| 112 |
+
provider, client_authenticator, identity_assertion_enabled=identity_assertion_enabled
|
| 113 |
+
).handle,
|
| 114 |
+
["POST", "OPTIONS"],
|
| 115 |
+
),
|
| 116 |
+
methods=["POST", "OPTIONS"],
|
| 117 |
+
),
|
| 118 |
+
]
|
| 119 |
+
|
| 120 |
+
if client_registration_options.enabled: # pragma: no branch
|
| 121 |
+
registration_handler = RegistrationHandler(
|
| 122 |
+
provider,
|
| 123 |
+
options=client_registration_options,
|
| 124 |
+
)
|
| 125 |
+
routes.append(
|
| 126 |
+
Route(
|
| 127 |
+
REGISTRATION_PATH,
|
| 128 |
+
endpoint=cors_middleware(
|
| 129 |
+
registration_handler.handle,
|
| 130 |
+
["POST", "OPTIONS"],
|
| 131 |
+
),
|
| 132 |
+
methods=["POST", "OPTIONS"],
|
| 133 |
+
)
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
if revocation_options.enabled: # pragma: no branch
|
| 137 |
+
revocation_handler = RevocationHandler(provider, client_authenticator)
|
| 138 |
+
routes.append(
|
| 139 |
+
Route(
|
| 140 |
+
REVOCATION_PATH,
|
| 141 |
+
endpoint=cors_middleware(
|
| 142 |
+
revocation_handler.handle,
|
| 143 |
+
["POST", "OPTIONS"],
|
| 144 |
+
),
|
| 145 |
+
methods=["POST", "OPTIONS"],
|
| 146 |
+
)
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
return routes
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def build_metadata(
|
| 153 |
+
issuer_url: AnyHttpUrl,
|
| 154 |
+
service_documentation_url: AnyHttpUrl | None,
|
| 155 |
+
client_registration_options: ClientRegistrationOptions,
|
| 156 |
+
revocation_options: RevocationOptions,
|
| 157 |
+
supports_identity_assertion: bool = False,
|
| 158 |
+
) -> OAuthMetadata:
|
| 159 |
+
authorization_url = AnyHttpUrl(str(issuer_url).rstrip("/") + AUTHORIZATION_PATH)
|
| 160 |
+
token_url = AnyHttpUrl(str(issuer_url).rstrip("/") + TOKEN_PATH)
|
| 161 |
+
|
| 162 |
+
grant_types_supported = ["authorization_code", "refresh_token"]
|
| 163 |
+
# SEP-990 / ext-auth §6: support for the ID-JAG flow is advertised as a grant PROFILE, not as
|
| 164 |
+
# the jwt-bearer grant type (which an AS might support for other purposes).
|
| 165 |
+
authorization_grant_profiles_supported: list[str] | None = None
|
| 166 |
+
if supports_identity_assertion:
|
| 167 |
+
grant_types_supported.append(JWT_BEARER_GRANT_TYPE)
|
| 168 |
+
authorization_grant_profiles_supported = [ID_JAG_GRANT_PROFILE]
|
| 169 |
+
|
| 170 |
+
# Create metadata
|
| 171 |
+
metadata = OAuthMetadata(
|
| 172 |
+
issuer=issuer_url,
|
| 173 |
+
authorization_endpoint=authorization_url,
|
| 174 |
+
token_endpoint=token_url,
|
| 175 |
+
scopes_supported=client_registration_options.valid_scopes,
|
| 176 |
+
response_types_supported=["code"],
|
| 177 |
+
response_modes_supported=None,
|
| 178 |
+
grant_types_supported=grant_types_supported,
|
| 179 |
+
token_endpoint_auth_methods_supported=["client_secret_post", "client_secret_basic"],
|
| 180 |
+
token_endpoint_auth_signing_alg_values_supported=None,
|
| 181 |
+
service_documentation=service_documentation_url,
|
| 182 |
+
ui_locales_supported=None,
|
| 183 |
+
op_policy_uri=None,
|
| 184 |
+
op_tos_uri=None,
|
| 185 |
+
introspection_endpoint=None,
|
| 186 |
+
code_challenge_methods_supported=["S256"],
|
| 187 |
+
authorization_grant_profiles_supported=authorization_grant_profiles_supported,
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
# Add registration endpoint if supported
|
| 191 |
+
if client_registration_options.enabled: # pragma: no branch
|
| 192 |
+
metadata.registration_endpoint = AnyHttpUrl(str(issuer_url).rstrip("/") + REGISTRATION_PATH)
|
| 193 |
+
|
| 194 |
+
# Add revocation endpoint if supported
|
| 195 |
+
if revocation_options.enabled: # pragma: no branch
|
| 196 |
+
metadata.revocation_endpoint = AnyHttpUrl(str(issuer_url).rstrip("/") + REVOCATION_PATH)
|
| 197 |
+
metadata.revocation_endpoint_auth_methods_supported = ["client_secret_post", "client_secret_basic"]
|
| 198 |
+
|
| 199 |
+
return metadata
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def build_resource_metadata_url(resource_server_url: AnyHttpUrl) -> AnyHttpUrl:
|
| 203 |
+
"""Build RFC 9728 compliant protected resource metadata URL.
|
| 204 |
+
|
| 205 |
+
Inserts /.well-known/oauth-protected-resource between host and resource path
|
| 206 |
+
as specified in RFC 9728 §3.1.
|
| 207 |
+
|
| 208 |
+
Args:
|
| 209 |
+
resource_server_url: The resource server URL (e.g., https://example.com/mcp)
|
| 210 |
+
|
| 211 |
+
Returns:
|
| 212 |
+
The metadata URL (e.g., https://example.com/.well-known/oauth-protected-resource/mcp)
|
| 213 |
+
"""
|
| 214 |
+
parsed = urlparse(str(resource_server_url))
|
| 215 |
+
# Handle trailing slash: if path is just "/", treat as empty
|
| 216 |
+
resource_path = parsed.path if parsed.path != "/" else ""
|
| 217 |
+
return AnyHttpUrl(f"{parsed.scheme}://{parsed.netloc}/.well-known/oauth-protected-resource{resource_path}")
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def create_protected_resource_routes(
|
| 221 |
+
resource_url: AnyHttpUrl,
|
| 222 |
+
authorization_servers: list[AnyHttpUrl],
|
| 223 |
+
scopes_supported: list[str] | None = None,
|
| 224 |
+
resource_name: str | None = None,
|
| 225 |
+
resource_documentation: AnyHttpUrl | None = None,
|
| 226 |
+
) -> list[Route]:
|
| 227 |
+
"""Create routes for OAuth 2.0 Protected Resource Metadata (RFC 9728).
|
| 228 |
+
|
| 229 |
+
Args:
|
| 230 |
+
resource_url: The URL of this resource server
|
| 231 |
+
authorization_servers: List of authorization servers that can issue tokens
|
| 232 |
+
scopes_supported: Optional list of scopes supported by this resource
|
| 233 |
+
resource_name: Optional human-readable name for this resource
|
| 234 |
+
resource_documentation: Optional URL to documentation for this resource
|
| 235 |
+
|
| 236 |
+
Returns:
|
| 237 |
+
List of Starlette routes for protected resource metadata
|
| 238 |
+
"""
|
| 239 |
+
metadata = ProtectedResourceMetadata(
|
| 240 |
+
resource=resource_url,
|
| 241 |
+
authorization_servers=authorization_servers,
|
| 242 |
+
scopes_supported=scopes_supported,
|
| 243 |
+
resource_name=resource_name,
|
| 244 |
+
resource_documentation=resource_documentation,
|
| 245 |
+
# bearer_methods_supported defaults to ["header"] in the model
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
handler = ProtectedResourceMetadataHandler(metadata)
|
| 249 |
+
|
| 250 |
+
# RFC 9728 §3.1: Register route at /.well-known/oauth-protected-resource + resource path
|
| 251 |
+
metadata_url = build_resource_metadata_url(resource_url)
|
| 252 |
+
# Extract just the path part for route registration
|
| 253 |
+
parsed = urlparse(str(metadata_url))
|
| 254 |
+
well_known_path = parsed.path
|
| 255 |
+
|
| 256 |
+
return [
|
| 257 |
+
Route(
|
| 258 |
+
well_known_path,
|
| 259 |
+
endpoint=cors_middleware(handler.handle, ["GET", "OPTIONS"]),
|
| 260 |
+
methods=["GET", "OPTIONS"],
|
| 261 |
+
)
|
| 262 |
+
]
|
.mcp-venv/lib/python3.13/site-packages/mcp/server/auth/settings.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class ClientRegistrationOptions(BaseModel):
|
| 5 |
+
enabled: bool = False
|
| 6 |
+
client_secret_expiry_seconds: int | None = None
|
| 7 |
+
valid_scopes: list[str] | None = None
|
| 8 |
+
default_scopes: list[str] | None = None
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class RevocationOptions(BaseModel):
|
| 12 |
+
enabled: bool = False
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class AuthSettings(BaseModel):
|
| 16 |
+
# Preserve empty URL paths so a path-less issuer/resource passed as a string keeps its
|
| 17 |
+
# canonical form (no trailing slash). RFC 8414/9207 issuer comparison is exact string
|
| 18 |
+
# comparison, so a spurious trailing slash would break it. See PR #2925 for the metadata
|
| 19 |
+
# models; this applies the same to the server's own configured URLs.
|
| 20 |
+
model_config = ConfigDict(url_preserve_empty_path=True)
|
| 21 |
+
|
| 22 |
+
issuer_url: AnyHttpUrl = Field(
|
| 23 |
+
...,
|
| 24 |
+
description="OAuth authorization server URL that issues tokens for this resource server.",
|
| 25 |
+
)
|
| 26 |
+
service_documentation_url: AnyHttpUrl | None = None
|
| 27 |
+
client_registration_options: ClientRegistrationOptions | None = None
|
| 28 |
+
revocation_options: RevocationOptions | None = None
|
| 29 |
+
required_scopes: list[str] | None = None
|
| 30 |
+
identity_assertion_enabled: bool = Field(
|
| 31 |
+
default=False,
|
| 32 |
+
description="Advertise and accept the SEP-990 Identity Assertion Authorization Grant "
|
| 33 |
+
"(the RFC 7523 jwt-bearer grant carrying an ID-JAG) at the token endpoint, for enterprise "
|
| 34 |
+
"IdP flows. The provider must implement `exchange_identity_assertion`.",
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
# Resource Server settings (when operating as RS only)
|
| 38 |
+
resource_server_url: AnyHttpUrl | None = Field(
|
| 39 |
+
...,
|
| 40 |
+
description="The URL of the MCP server to be used as the resource identifier "
|
| 41 |
+
"and base route to look up OAuth Protected Resource Metadata.",
|
| 42 |
+
)
|
.mcp-venv/lib/python3.13/site-packages/mcp/server/caching.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Server-side caching hints (SEP-2549, protocol revision 2026-07-28).
|
| 2 |
+
|
| 3 |
+
Results for the cacheable methods carry `ttlMs`/`cacheScope` freshness hints.
|
| 4 |
+
A handler sets them by returning a result with explicit `ttl_ms`/`cache_scope`
|
| 5 |
+
values; `Server(cache_hints={method: CacheHint(...)})` fills them for handlers
|
| 6 |
+
that don't. Fields the handler set win, per field, so a server-wide hint never
|
| 7 |
+
overrides a handler's explicit choice.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from collections.abc import Mapping
|
| 13 |
+
from dataclasses import dataclass
|
| 14 |
+
from typing import Any, Literal, TypeVar
|
| 15 |
+
|
| 16 |
+
import mcp_types as types
|
| 17 |
+
from mcp_types.methods import CACHEABLE_METHODS, CacheableMethod
|
| 18 |
+
|
| 19 |
+
__all__ = ["CACHEABLE_METHODS", "CacheHint", "CacheableMethod", "apply_cache_hint", "validate_cache_hints"]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass(frozen=True, slots=True)
|
| 23 |
+
class CacheHint:
|
| 24 |
+
"""Freshness hint for one cacheable method's results.
|
| 25 |
+
|
| 26 |
+
`ttl_ms` is how long, in milliseconds, a client may consider the result
|
| 27 |
+
fresh (`0` means immediately stale). `scope` is whether a cached result may
|
| 28 |
+
be shared across authorization contexts (`"public"`) or only reused within
|
| 29 |
+
the one that produced it (`"private"`).
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
ttl_ms: int = 0
|
| 33 |
+
scope: Literal["public", "private"] = "private"
|
| 34 |
+
|
| 35 |
+
def __post_init__(self) -> None:
|
| 36 |
+
if self.ttl_ms < 0:
|
| 37 |
+
raise ValueError(f"ttl_ms must be >= 0, got {self.ttl_ms}")
|
| 38 |
+
if self.scope not in ("public", "private"):
|
| 39 |
+
raise ValueError(f"scope must be 'public' or 'private', got {self.scope!r}")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
CacheableResultT = TypeVar("CacheableResultT", bound=types.CacheableResult)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def apply_cache_hint(result: CacheableResultT, hint: CacheHint) -> CacheableResultT:
|
| 46 |
+
"""Fill `ttl_ms`/`cache_scope` on `result` from `hint`.
|
| 47 |
+
|
| 48 |
+
Per-field: a field the handler set explicitly - even to its default value,
|
| 49 |
+
tracked via `model_fields_set` - is left alone; only unset fields take the
|
| 50 |
+
hint. A handler constructing results with `model_construct` bypasses that
|
| 51 |
+
tracking and is treated as having set nothing.
|
| 52 |
+
"""
|
| 53 |
+
update: dict[str, int | str] = {}
|
| 54 |
+
if "ttl_ms" not in result.model_fields_set:
|
| 55 |
+
update["ttl_ms"] = hint.ttl_ms
|
| 56 |
+
if "cache_scope" not in result.model_fields_set:
|
| 57 |
+
update["cache_scope"] = hint.scope
|
| 58 |
+
return result.model_copy(update=update) if update else result
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def validate_cache_hints(cache_hints: Mapping[Any, Any] | None) -> dict[str, CacheHint]:
|
| 62 |
+
"""Validate a `cache_hints` constructor argument into a plain dict.
|
| 63 |
+
|
| 64 |
+
The `Server`/`MCPServer` signatures already close the key set and value
|
| 65 |
+
type for type-checked callers; this runtime gate is deliberately loose in
|
| 66 |
+
its parameter so it covers everyone else (e.g. a map deserialized from
|
| 67 |
+
config) - a bad entry fails at construction, not on the first request to
|
| 68 |
+
that method.
|
| 69 |
+
|
| 70 |
+
Raises:
|
| 71 |
+
ValueError: If a key is not a cacheable method.
|
| 72 |
+
TypeError: If a value is not a `CacheHint`.
|
| 73 |
+
"""
|
| 74 |
+
if cache_hints is None:
|
| 75 |
+
return {}
|
| 76 |
+
# repr-format keys so a non-string key raises this ValueError, not a TypeError from sorted/join.
|
| 77 |
+
unknown = sorted(repr(method) for method in cache_hints if method not in CACHEABLE_METHODS)
|
| 78 |
+
if unknown:
|
| 79 |
+
raise ValueError(f"cache_hints keys must be cacheable methods (see CacheableMethod); got: {', '.join(unknown)}")
|
| 80 |
+
validated: dict[str, CacheHint] = {}
|
| 81 |
+
for method, hint in cache_hints.items():
|
| 82 |
+
if not isinstance(hint, CacheHint):
|
| 83 |
+
raise TypeError(f"cache_hints[{method!r}] must be a CacheHint, got {type(hint).__name__}")
|
| 84 |
+
validated[method] = hint
|
| 85 |
+
return validated
|
.mcp-venv/lib/python3.13/site-packages/mcp/server/connection.py
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""`Connection` - per-client connection state and the standalone outbound channel.
|
| 2 |
+
|
| 3 |
+
Always present on `Context` (never `None`), even in stateless deployments.
|
| 4 |
+
Holds peer info, per-connection scratch `state` and an `exit_stack` for
|
| 5 |
+
teardown, and an `Outbound` for the standalone stream (the SSE GET stream in
|
| 6 |
+
streamable HTTP, or the single duplex stream in stdio).
|
| 7 |
+
|
| 8 |
+
Construct via the factories: `Connection.from_envelope` for the 2026-era
|
| 9 |
+
single-exchange path (born ready, no back-channel) and `Connection.for_loop`
|
| 10 |
+
for the handshake-driven loop path. Both populate `protocol_version` so the
|
| 11 |
+
kernel reads it as a fact.
|
| 12 |
+
|
| 13 |
+
`notify` is best-effort: it never raises. If there's no standalone channel
|
| 14 |
+
or the stream has been dropped, the notification is debug-logged and silently
|
| 15 |
+
discarded - server-initiated notifications are inherently advisory.
|
| 16 |
+
`send_raw_request` raises `NoBackChannelError` when there's no channel; `ping`
|
| 17 |
+
is the only spec-sanctioned standalone request.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import logging
|
| 23 |
+
from collections.abc import Mapping
|
| 24 |
+
from contextlib import AsyncExitStack
|
| 25 |
+
from typing import Any, Final, TypeVar, get_args, overload
|
| 26 |
+
|
| 27 |
+
import anyio
|
| 28 |
+
from mcp_types import (
|
| 29 |
+
LOG_LEVEL_META_KEY,
|
| 30 |
+
ClientCapabilities,
|
| 31 |
+
CreateMessageRequest,
|
| 32 |
+
CreateMessageResult,
|
| 33 |
+
ElicitRequest,
|
| 34 |
+
ElicitResult,
|
| 35 |
+
EmptyResult,
|
| 36 |
+
Implementation,
|
| 37 |
+
InitializeRequestParams,
|
| 38 |
+
ListRootsRequest,
|
| 39 |
+
ListRootsResult,
|
| 40 |
+
LoggingLevel,
|
| 41 |
+
PingRequest,
|
| 42 |
+
Request,
|
| 43 |
+
)
|
| 44 |
+
from mcp_types import methods as _methods
|
| 45 |
+
from mcp_types.version import LATEST_HANDSHAKE_VERSION, MODERN_PROTOCOL_VERSIONS
|
| 46 |
+
from pydantic import BaseModel, ValidationError
|
| 47 |
+
from typing_extensions import deprecated
|
| 48 |
+
|
| 49 |
+
from mcp.shared.dispatcher import CallOptions, Outbound
|
| 50 |
+
from mcp.shared.exceptions import MCPDeprecationWarning, NoBackChannelError
|
| 51 |
+
from mcp.shared.peer import Meta, dump_params
|
| 52 |
+
from mcp.shared.subscriptions import LISTEN_STREAM_METHODS
|
| 53 |
+
|
| 54 |
+
__all__ = ["Connection"]
|
| 55 |
+
|
| 56 |
+
logger = logging.getLogger(__name__)
|
| 57 |
+
# `Connection.log`'s `logger` parameter (public API, the spec's logger-name
|
| 58 |
+
# field) shadows the module logger inside that method; this alias keeps the
|
| 59 |
+
# module logger reachable there.
|
| 60 |
+
_logger = logger
|
| 61 |
+
|
| 62 |
+
_LOG_LEVELS: Final[tuple[LoggingLevel, ...]] = get_args(LoggingLevel)
|
| 63 |
+
"""Severity-ascending, from the `LoggingLevel` literal's declaration order (the
|
| 64 |
+
RFC 5424 scale) - the literal is the single source of the ordering."""
|
| 65 |
+
|
| 66 |
+
_ALL_LOG_LEVELS: Final[frozenset[LoggingLevel]] = frozenset(_LOG_LEVELS)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def allowed_log_levels(protocol_version: str, meta: Mapping[str, Any] | None) -> frozenset[LoggingLevel]:
|
| 70 |
+
"""The `notifications/message` levels deliverable for one inbound request.
|
| 71 |
+
|
| 72 |
+
2026-07-28+ makes log delivery a per-request opt-in (server/utilities/
|
| 73 |
+
logging): the client sets the reserved `io.modelcontextprotocol/logLevel`
|
| 74 |
+
`_meta` key, absent means no levels - the server MUST NOT send - and
|
| 75 |
+
present means that level and above. An unrecognized value reads as absent;
|
| 76 |
+
spec methods already reject a malformed value at surface validation
|
| 77 |
+
before any handler runs, so that arm only serves custom methods, where
|
| 78 |
+
dropping is the safe direction. Connection-scoped emitters pass
|
| 79 |
+
`meta=None`: `logging/setLevel` is gone at 2026 and log delivery is
|
| 80 |
+
request-scoped only, so they deliver nothing. Handshake versions keep
|
| 81 |
+
their `logging/setLevel`-era semantics: every level may be sent, filtering
|
| 82 |
+
is the application's `logging/setLevel` handler's job as before.
|
| 83 |
+
"""
|
| 84 |
+
if protocol_version not in MODERN_PROTOCOL_VERSIONS:
|
| 85 |
+
return _ALL_LOG_LEVELS
|
| 86 |
+
requested = (meta or {}).get(LOG_LEVEL_META_KEY)
|
| 87 |
+
if requested not in _LOG_LEVELS:
|
| 88 |
+
return frozenset()
|
| 89 |
+
return frozenset(_LOG_LEVELS[_LOG_LEVELS.index(requested) :])
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
ResultT = TypeVar("ResultT", bound=BaseModel)
|
| 93 |
+
|
| 94 |
+
# Result types for the spec's server-to-client request set, used by
|
| 95 |
+
# `Connection.send_request` to infer the result type. If the spec's request
|
| 96 |
+
# set grows substantially, consider declaring the result mapping on the
|
| 97 |
+
# request types themselves (a `__mcp_result__` ClassVar read via a structural
|
| 98 |
+
# protocol) so this table and the overload ladder don't need maintaining.
|
| 99 |
+
_RESULT_FOR: dict[type[Request[Any, Any]], type[BaseModel]] = {
|
| 100 |
+
CreateMessageRequest: CreateMessageResult,
|
| 101 |
+
ElicitRequest: ElicitResult,
|
| 102 |
+
ListRootsRequest: ListRootsResult,
|
| 103 |
+
PingRequest: EmptyResult,
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
_ModelT = TypeVar("_ModelT", bound=BaseModel)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _typed(model: type[_ModelT], raw: Any) -> _ModelT | None:
|
| 111 |
+
"""Validate a raw envelope value into a typed model.
|
| 112 |
+
|
| 113 |
+
A missing, null or mis-shaped value falls through to `ValidationError`
|
| 114 |
+
and is treated as not supplied so the request still routes. Spec methods
|
| 115 |
+
are separately re-validated by the kernel's per-version params surface,
|
| 116 |
+
which types the reserved `_meta` keys strictly.
|
| 117 |
+
"""
|
| 118 |
+
try:
|
| 119 |
+
return model.model_validate(raw, by_name=False)
|
| 120 |
+
except ValidationError:
|
| 121 |
+
return None
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _notification_params(payload: dict[str, Any] | None, meta: Meta | None) -> dict[str, Any] | None:
|
| 125 |
+
if not meta:
|
| 126 |
+
return payload
|
| 127 |
+
out = dict(payload or {})
|
| 128 |
+
out["_meta"] = meta
|
| 129 |
+
return out
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
class _NoChannelOutbound:
|
| 133 |
+
"""Connection-scoped `Outbound` for the no-back-channel case.
|
| 134 |
+
|
| 135 |
+
The structural answer to "this connection cannot push to its peer":
|
| 136 |
+
`send_raw_request` raises `NoBackChannelError`; `notify` drops with a
|
| 137 |
+
debug log. `Connection.from_envelope` installs this so the modern
|
| 138 |
+
single-exchange path never needs a mode flag - the channel itself says no.
|
| 139 |
+
"""
|
| 140 |
+
|
| 141 |
+
async def send_raw_request(
|
| 142 |
+
self,
|
| 143 |
+
method: str,
|
| 144 |
+
params: Mapping[str, Any] | None,
|
| 145 |
+
opts: CallOptions | None = None,
|
| 146 |
+
) -> dict[str, Any]:
|
| 147 |
+
raise NoBackChannelError(method)
|
| 148 |
+
|
| 149 |
+
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
|
| 150 |
+
logger.debug("dropped %s: no standalone channel", method)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
_NO_CHANNEL = _NoChannelOutbound()
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
class NotifyOnlyOutbound(_NoChannelOutbound):
|
| 157 |
+
"""Connection-scoped `Outbound` that forwards notifications and refuses requests.
|
| 158 |
+
|
| 159 |
+
Installed by `serve_dual_era_loop` for modern (2026-07-28+) connections
|
| 160 |
+
over duplex stream transports: the pipe is real, so server notifications
|
| 161 |
+
ride it, but the modern protocol forbids server-initiated JSON-RPC
|
| 162 |
+
requests, so `send_raw_request` (inherited) refuses by construction.
|
| 163 |
+
|
| 164 |
+
Change notifications (`notifications/*/list_changed`,
|
| 165 |
+
`notifications/resources/updated`) are dropped with a debug log: at this
|
| 166 |
+
era they reach a client only through a `subscriptions/listen` stream it
|
| 167 |
+
opened, so a bare copy on the shared channel would be an unrequested
|
| 168 |
+
notification. Publish them on the server's `SubscriptionBus` instead.
|
| 169 |
+
"""
|
| 170 |
+
|
| 171 |
+
def __init__(self, outbound: Outbound) -> None:
|
| 172 |
+
self._outbound = outbound
|
| 173 |
+
|
| 174 |
+
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
|
| 175 |
+
# At the 2026-07-28 era these are `subscriptions/listen` stream goods
|
| 176 |
+
# only: the spec forbids sending a change notification a subscription
|
| 177 |
+
# did not request, and listen streams deliver them (stamped, filtered)
|
| 178 |
+
# via the request-scoped outbound, never this connection-scoped channel.
|
| 179 |
+
if method in LISTEN_STREAM_METHODS:
|
| 180 |
+
logger.debug("dropped %s: delivered via subscriptions/listen at this era", method)
|
| 181 |
+
return
|
| 182 |
+
await self._outbound.notify(method, params, opts)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
class Connection:
|
| 186 |
+
"""Per-client connection state and standalone-stream `Outbound`.
|
| 187 |
+
|
| 188 |
+
Construct via `from_envelope` (modern single-exchange: born ready, no
|
| 189 |
+
back-channel) or `for_loop` (handshake-driven: ready once the client's
|
| 190 |
+
`notifications/initialized` arrives). Either way `protocol_version` is
|
| 191 |
+
populated at construction.
|
| 192 |
+
"""
|
| 193 |
+
|
| 194 |
+
outbound: Outbound
|
| 195 |
+
"""The connection-scoped channel for server-initiated messages."""
|
| 196 |
+
|
| 197 |
+
session_id: str | None
|
| 198 |
+
|
| 199 |
+
client_capabilities: ClientCapabilities | None
|
| 200 |
+
"""The capabilities the peer declared: the handshake's on the loop path,
|
| 201 |
+
the request envelope's on the modern path. `None` when none were declared.
|
| 202 |
+
Kept in lockstep with `client_params` by its setter, and settable on its
|
| 203 |
+
own for the modern envelope, where capabilities are required but client
|
| 204 |
+
info is optional (spec PR #3002) - capability checks must not depend on the
|
| 205 |
+
peer having identified itself."""
|
| 206 |
+
|
| 207 |
+
protocol_version: str
|
| 208 |
+
"""The protocol version this connection speaks. Populated at construction
|
| 209 |
+
by the factory and overwritten by `_handle_initialize` once the handshake
|
| 210 |
+
commits on the loop path."""
|
| 211 |
+
|
| 212 |
+
initialized: anyio.Event
|
| 213 |
+
"""Set when `notifications/initialized` arrives (matches TS `oninitialized`);
|
| 214 |
+
the point from which the spec permits server-initiated requests beyond
|
| 215 |
+
ping/logging. Pre-set on connections built via `from_envelope`."""
|
| 216 |
+
|
| 217 |
+
state: dict[str, Any]
|
| 218 |
+
"""Per-connection scratch state; persists across requests on this connection."""
|
| 219 |
+
|
| 220 |
+
exit_stack: AsyncExitStack
|
| 221 |
+
"""Per-connection teardown, unwound LIFO (shielded) when the connection
|
| 222 |
+
closes. Push cleanup from handlers or middleware; exceptions are logged
|
| 223 |
+
and swallowed."""
|
| 224 |
+
|
| 225 |
+
def __init__(
|
| 226 |
+
self,
|
| 227 |
+
outbound: Outbound,
|
| 228 |
+
*,
|
| 229 |
+
protocol_version: str,
|
| 230 |
+
session_id: str | None = None,
|
| 231 |
+
client_params: InitializeRequestParams | None = None,
|
| 232 |
+
) -> None:
|
| 233 |
+
self.outbound = outbound
|
| 234 |
+
self.protocol_version = protocol_version
|
| 235 |
+
self.session_id = session_id
|
| 236 |
+
self.client_capabilities = None
|
| 237 |
+
self.client_params = client_params
|
| 238 |
+
self.initialized = anyio.Event()
|
| 239 |
+
self.state = {}
|
| 240 |
+
self.exit_stack = AsyncExitStack()
|
| 241 |
+
|
| 242 |
+
@property
|
| 243 |
+
def client_params(self) -> InitializeRequestParams | None:
|
| 244 |
+
"""The full `initialize` request params, or the equivalent built from the
|
| 245 |
+
2026-era envelope. `None` when no client info was supplied."""
|
| 246 |
+
return self._client_params
|
| 247 |
+
|
| 248 |
+
@client_params.setter
|
| 249 |
+
def client_params(self, value: InitializeRequestParams | None) -> None:
|
| 250 |
+
# Assignment is the sync point: recording full client params (the
|
| 251 |
+
# handshake commit, or a modern envelope carrying client info) also
|
| 252 |
+
# records the capabilities fact, so the two can never drift. Clearing
|
| 253 |
+
# to `None` leaves `client_capabilities` alone - the modern envelope
|
| 254 |
+
# declares capabilities without client info.
|
| 255 |
+
self._client_params = value
|
| 256 |
+
if value is not None:
|
| 257 |
+
self.client_capabilities = value.capabilities
|
| 258 |
+
|
| 259 |
+
@classmethod
|
| 260 |
+
def from_envelope(
|
| 261 |
+
cls,
|
| 262 |
+
protocol_version: str,
|
| 263 |
+
client_info: Any,
|
| 264 |
+
client_capabilities: Any,
|
| 265 |
+
*,
|
| 266 |
+
outbound: Outbound = _NO_CHANNEL,
|
| 267 |
+
) -> Connection:
|
| 268 |
+
"""A born-ready connection populated from a request's `_meta` envelope.
|
| 269 |
+
|
| 270 |
+
`protocol_version` must be an already-validated version string - the
|
| 271 |
+
inbound classification ladder owns rejecting non-string or unsupported
|
| 272 |
+
values. `client_info` and `client_capabilities` are the raw envelope
|
| 273 |
+
values: this constructor owns turning them into connection identity,
|
| 274 |
+
identically on every modern entry, so a mis-shaped value degrades to
|
| 275 |
+
not-supplied rather than failing the request. `initialized` is set,
|
| 276 |
+
well-formed capabilities are recorded as `client_capabilities` (client
|
| 277 |
+
info is optional per spec PR #3002, so capability checks never depend on
|
| 278 |
+
it), and the full `client_params` is additionally synthesized when
|
| 279 |
+
client info was supplied too. `outbound` defaults to the no-channel
|
| 280 |
+
sentinel for the single-exchange HTTP path; duplex modern transports
|
| 281 |
+
(e.g. stdio) pass a notify-only wrapper around the dispatcher so
|
| 282 |
+
server notifications ride the pipe while server-initiated requests
|
| 283 |
+
stay refused.
|
| 284 |
+
"""
|
| 285 |
+
info = _typed(Implementation, client_info)
|
| 286 |
+
capabilities = _typed(ClientCapabilities, client_capabilities)
|
| 287 |
+
client_params = None
|
| 288 |
+
if info is not None and capabilities is not None:
|
| 289 |
+
client_params = InitializeRequestParams(
|
| 290 |
+
protocol_version=protocol_version,
|
| 291 |
+
capabilities=capabilities,
|
| 292 |
+
client_info=info,
|
| 293 |
+
)
|
| 294 |
+
connection = cls(outbound, protocol_version=protocol_version, client_params=client_params)
|
| 295 |
+
connection.client_capabilities = capabilities
|
| 296 |
+
connection.initialized.set()
|
| 297 |
+
return connection
|
| 298 |
+
|
| 299 |
+
@classmethod
|
| 300 |
+
def for_loop(
|
| 301 |
+
cls,
|
| 302 |
+
outbound: Outbound,
|
| 303 |
+
*,
|
| 304 |
+
session_id: str | None = None,
|
| 305 |
+
protocol_version_hint: str | None = None,
|
| 306 |
+
) -> Connection:
|
| 307 |
+
"""A connection for the handshake-driven loop path.
|
| 308 |
+
|
| 309 |
+
Not born-ready: `initialized` is set later by the kernel when
|
| 310 |
+
`notifications/initialized` arrives. `protocol_version` is seeded from
|
| 311 |
+
the transport hint (or `LATEST_HANDSHAKE_VERSION`) so it's never `None`;
|
| 312 |
+
the handshake overwrites it once negotiated.
|
| 313 |
+
"""
|
| 314 |
+
return cls(
|
| 315 |
+
outbound,
|
| 316 |
+
protocol_version=protocol_version_hint if protocol_version_hint is not None else LATEST_HANDSHAKE_VERSION,
|
| 317 |
+
session_id=session_id,
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
@property
|
| 321 |
+
def has_standalone_channel(self) -> bool:
|
| 322 |
+
"""Whether this connection has a real back-channel for server-initiated
|
| 323 |
+
messages. Derived from `outbound` - the no-channel sentinel is the only
|
| 324 |
+
case that doesn't.
|
| 325 |
+
|
| 326 |
+
Channel presence, not request permission: a modern (2026-07-28+)
|
| 327 |
+
duplex connection has a channel that carries notifications while
|
| 328 |
+
`send_raw_request` still refuses, because the protocol forbids
|
| 329 |
+
server-initiated requests."""
|
| 330 |
+
return self.outbound is not _NO_CHANNEL
|
| 331 |
+
|
| 332 |
+
@property
|
| 333 |
+
def initialize_accepted(self) -> bool:
|
| 334 |
+
"""True once the inbound request gate is open: `initialize` recorded the
|
| 335 |
+
peer info, or the handshake completed outright (born-ready, or a bare
|
| 336 |
+
`notifications/initialized`). Derived, never stored."""
|
| 337 |
+
return self.client_params is not None or self.initialized.is_set()
|
| 338 |
+
|
| 339 |
+
async def send_raw_request(
|
| 340 |
+
self,
|
| 341 |
+
method: str,
|
| 342 |
+
params: Mapping[str, Any] | None,
|
| 343 |
+
opts: CallOptions | None = None,
|
| 344 |
+
) -> dict[str, Any]:
|
| 345 |
+
"""Send a raw request on the standalone stream.
|
| 346 |
+
|
| 347 |
+
Low-level `Outbound` channel. Prefer the typed `send_request` or the
|
| 348 |
+
convenience methods below; use this directly only for off-spec
|
| 349 |
+
messages. `opts` carries per-call `timeout` / `on_progress` /
|
| 350 |
+
resumption hints; see `CallOptions`.
|
| 351 |
+
|
| 352 |
+
Raises:
|
| 353 |
+
MCPError: The peer responded with an error.
|
| 354 |
+
NoBackChannelError: no back-channel for server-initiated requests -
|
| 355 |
+
`has_standalone_channel` is `False`, or a modern (2026-07-28+)
|
| 356 |
+
connection, where the protocol forbids them.
|
| 357 |
+
"""
|
| 358 |
+
return await self.outbound.send_raw_request(method, params, opts)
|
| 359 |
+
|
| 360 |
+
@overload
|
| 361 |
+
async def send_request(
|
| 362 |
+
self, req: CreateMessageRequest, *, opts: CallOptions | None = None
|
| 363 |
+
) -> CreateMessageResult: ...
|
| 364 |
+
@overload
|
| 365 |
+
async def send_request(self, req: ElicitRequest, *, opts: CallOptions | None = None) -> ElicitResult: ...
|
| 366 |
+
@overload
|
| 367 |
+
async def send_request(self, req: ListRootsRequest, *, opts: CallOptions | None = None) -> ListRootsResult: ...
|
| 368 |
+
@overload
|
| 369 |
+
async def send_request(self, req: PingRequest, *, opts: CallOptions | None = None) -> EmptyResult: ...
|
| 370 |
+
@overload
|
| 371 |
+
async def send_request(
|
| 372 |
+
self, req: Request[Any, Any], *, result_type: type[ResultT], opts: CallOptions | None = None
|
| 373 |
+
) -> ResultT: ...
|
| 374 |
+
async def send_request(
|
| 375 |
+
self,
|
| 376 |
+
req: Request[Any, Any],
|
| 377 |
+
*,
|
| 378 |
+
result_type: type[BaseModel] | None = None,
|
| 379 |
+
opts: CallOptions | None = None,
|
| 380 |
+
) -> BaseModel:
|
| 381 |
+
"""Send a typed server-to-client request and return its typed result.
|
| 382 |
+
|
| 383 |
+
For spec request types the result type is inferred. For custom requests
|
| 384 |
+
pass `result_type=` explicitly.
|
| 385 |
+
|
| 386 |
+
Raises:
|
| 387 |
+
MCPError: The peer responded with an error.
|
| 388 |
+
NoBackChannelError: No back-channel for server-initiated requests.
|
| 389 |
+
pydantic.ValidationError: The peer's result does not match the expected result type.
|
| 390 |
+
KeyError: `result_type` omitted for a non-spec request type.
|
| 391 |
+
"""
|
| 392 |
+
raw = await self.send_raw_request(req.method, dump_params(req.params), opts)
|
| 393 |
+
if req.method in _methods.MONOLITH_REQUESTS:
|
| 394 |
+
try:
|
| 395 |
+
_methods.validate_client_result(req.method, self.protocol_version, raw)
|
| 396 |
+
except KeyError:
|
| 397 |
+
pass
|
| 398 |
+
cls = result_type if result_type is not None else _RESULT_FOR[type(req)]
|
| 399 |
+
return cls.model_validate(raw, by_name=False)
|
| 400 |
+
|
| 401 |
+
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
|
| 402 |
+
"""Send a best-effort notification on the standalone stream.
|
| 403 |
+
|
| 404 |
+
Never raises. If there's no standalone channel or the stream is broken,
|
| 405 |
+
the notification is dropped and debug-logged.
|
| 406 |
+
"""
|
| 407 |
+
try:
|
| 408 |
+
await self.outbound.notify(method, params, opts)
|
| 409 |
+
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
|
| 410 |
+
logger.debug("dropped %s: standalone stream closed", method)
|
| 411 |
+
|
| 412 |
+
async def ping(self, *, meta: Meta | None = None, opts: CallOptions | None = None) -> None:
|
| 413 |
+
"""Send a `ping` request on the standalone stream.
|
| 414 |
+
|
| 415 |
+
Raises:
|
| 416 |
+
MCPError: The peer responded with an error.
|
| 417 |
+
NoBackChannelError: no back-channel for server-initiated requests -
|
| 418 |
+
`has_standalone_channel` is `False`, or a modern (2026-07-28+)
|
| 419 |
+
connection, where the protocol forbids them.
|
| 420 |
+
"""
|
| 421 |
+
await self.send_raw_request("ping", dump_params(None, meta), opts)
|
| 422 |
+
|
| 423 |
+
@deprecated("The logging capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
|
| 424 |
+
async def log(self, level: LoggingLevel, data: Any, logger: str | None = None, *, meta: Meta | None = None) -> None:
|
| 425 |
+
"""Send a `notifications/message` log entry on the standalone stream. Best-effort.
|
| 426 |
+
|
| 427 |
+
On 2026-07-28+ connections this never sends: log delivery is a
|
| 428 |
+
per-request opt-in that rides the requesting stream (`ctx.log`,
|
| 429 |
+
`ctx.session.send_log_message`), and the standalone stream is
|
| 430 |
+
forbidden from carrying `notifications/message`, so the entry is
|
| 431 |
+
debug-logged and dropped.
|
| 432 |
+
"""
|
| 433 |
+
if level not in allowed_log_levels(self.protocol_version, None):
|
| 434 |
+
_logger.debug("dropped notifications/message: no connection-wide log delivery at %s", self.protocol_version)
|
| 435 |
+
return
|
| 436 |
+
params: dict[str, Any] = {"level": level, "data": data}
|
| 437 |
+
if logger is not None:
|
| 438 |
+
params["logger"] = logger
|
| 439 |
+
await self.notify("notifications/message", _notification_params(params, meta))
|
| 440 |
+
|
| 441 |
+
async def send_tool_list_changed(self, *, meta: Meta | None = None) -> None:
|
| 442 |
+
await self.notify("notifications/tools/list_changed", _notification_params(None, meta))
|
| 443 |
+
|
| 444 |
+
async def send_prompt_list_changed(self, *, meta: Meta | None = None) -> None:
|
| 445 |
+
await self.notify("notifications/prompts/list_changed", _notification_params(None, meta))
|
| 446 |
+
|
| 447 |
+
async def send_resource_list_changed(self, *, meta: Meta | None = None) -> None:
|
| 448 |
+
await self.notify("notifications/resources/list_changed", _notification_params(None, meta))
|
| 449 |
+
|
| 450 |
+
async def send_resource_updated(self, uri: str, *, meta: Meta | None = None) -> None:
|
| 451 |
+
await self.notify("notifications/resources/updated", _notification_params({"uri": uri}, meta))
|
| 452 |
+
|
| 453 |
+
def check_capability(self, capability: ClientCapabilities) -> bool:
|
| 454 |
+
"""Return whether the connected client declared the given capability.
|
| 455 |
+
|
| 456 |
+
Returns `False` when no capabilities have been recorded.
|
| 457 |
+
"""
|
| 458 |
+
# TODO(L53): redesign - mirrors v1 ServerSession.check_client_capability
|
| 459 |
+
# verbatim for parity.
|
| 460 |
+
if self.client_capabilities is None:
|
| 461 |
+
return False
|
| 462 |
+
have = self.client_capabilities
|
| 463 |
+
if capability.roots is not None:
|
| 464 |
+
if have.roots is None:
|
| 465 |
+
return False
|
| 466 |
+
if capability.roots.list_changed and not have.roots.list_changed:
|
| 467 |
+
return False
|
| 468 |
+
if capability.sampling is not None:
|
| 469 |
+
if have.sampling is None:
|
| 470 |
+
return False
|
| 471 |
+
if capability.sampling.context is not None and have.sampling.context is None:
|
| 472 |
+
return False
|
| 473 |
+
if capability.sampling.tools is not None and have.sampling.tools is None:
|
| 474 |
+
return False
|
| 475 |
+
if capability.elicitation is not None and have.elicitation is None:
|
| 476 |
+
return False
|
| 477 |
+
if capability.experimental is not None:
|
| 478 |
+
if have.experimental is None:
|
| 479 |
+
return False
|
| 480 |
+
for k, v in capability.experimental.items():
|
| 481 |
+
if k not in have.experimental or have.experimental[k] != v:
|
| 482 |
+
return False
|
| 483 |
+
if capability.extensions is not None:
|
| 484 |
+
# SEP-2133: an extension is supported when the client declares its
|
| 485 |
+
# identifier. Settings are negotiated per-extension (the client may
|
| 486 |
+
# advertise more than the server asks for), so presence - not value
|
| 487 |
+
# equality - is the meaningful check.
|
| 488 |
+
if have.extensions is None:
|
| 489 |
+
return False
|
| 490 |
+
for identifier in capability.extensions:
|
| 491 |
+
if identifier not in have.extensions:
|
| 492 |
+
return False
|
| 493 |
+
return True
|