Brettapps commited on
Commit
d451ff3
·
verified ·
1 Parent(s): d8ebcc3

Upload folder using huggingface_hub (part 3)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/INSTALLER +1 -0
  2. .mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/METADATA +175 -0
  3. .mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/RECORD +253 -0
  4. .mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/REQUESTED +0 -0
  5. .mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/WHEEL +4 -0
  6. .mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/entry_points.txt +2 -0
  7. .mcp-venv/lib/python3.13/site-packages/mcp-2.0.0.dist-info/licenses/LICENSE +21 -0
  8. .mcp-venv/lib/python3.13/site-packages/mcp/client/auth/extensions/client_credentials.py +333 -0
  9. .mcp-venv/lib/python3.13/site-packages/mcp/client/auth/extensions/identity_assertion.py +213 -0
  10. .mcp-venv/lib/python3.13/site-packages/mcp/client/auth/oauth2.py +785 -0
  11. .mcp-venv/lib/python3.13/site-packages/mcp/client/auth/utils.py +427 -0
  12. .mcp-venv/lib/python3.13/site-packages/mcp/client/caching.py +387 -0
  13. .mcp-venv/lib/python3.13/site-packages/mcp/client/client.py +950 -0
  14. .mcp-venv/lib/python3.13/site-packages/mcp/client/context.py +5 -0
  15. .mcp-venv/lib/python3.13/site-packages/mcp/client/extension.py +196 -0
  16. .mcp-venv/lib/python3.13/site-packages/mcp/client/session.py +1462 -0
  17. .mcp-venv/lib/python3.13/site-packages/mcp/client/session_group.py +449 -0
  18. .mcp-venv/lib/python3.13/site-packages/mcp/client/sse.py +161 -0
  19. .mcp-venv/lib/python3.13/site-packages/mcp/client/stdio.py +354 -0
  20. .mcp-venv/lib/python3.13/site-packages/mcp/client/streamable_http.py +710 -0
  21. .mcp-venv/lib/python3.13/site-packages/mcp/client/subscriptions.py +282 -0
  22. .mcp-venv/lib/python3.13/site-packages/mcp/os/__init__.py +1 -0
  23. .mcp-venv/lib/python3.13/site-packages/mcp/os/posix/__init__.py +1 -0
  24. .mcp-venv/lib/python3.13/site-packages/mcp/os/posix/utilities.py +63 -0
  25. .mcp-venv/lib/python3.13/site-packages/mcp/os/win32/__init__.py +1 -0
  26. .mcp-venv/lib/python3.13/site-packages/mcp/os/win32/utilities.py +292 -0
  27. .mcp-venv/lib/python3.13/site-packages/mcp/py.typed +0 -0
  28. .mcp-venv/lib/python3.13/site-packages/mcp/server/__init__.py +7 -0
  29. .mcp-venv/lib/python3.13/site-packages/mcp/server/__main__.py +24 -0
  30. .mcp-venv/lib/python3.13/site-packages/mcp/server/_otel.py +72 -0
  31. .mcp-venv/lib/python3.13/site-packages/mcp/server/_streamable_http_modern.py +464 -0
  32. .mcp-venv/lib/python3.13/site-packages/mcp/server/apps.py +241 -0
  33. .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/__init__.py +1 -0
  34. .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/errors.py +5 -0
  35. .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/__init__.py +1 -0
  36. .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/authorize.py +225 -0
  37. .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/metadata.py +29 -0
  38. .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/register.py +154 -0
  39. .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/revoke.py +87 -0
  40. .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/handlers/token.py +275 -0
  41. .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/json_response.py +10 -0
  42. .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/middleware/__init__.py +1 -0
  43. .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/middleware/auth_context.py +46 -0
  44. .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/middleware/bearer_auth.py +143 -0
  45. .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/middleware/client_auth.py +119 -0
  46. .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/provider.py +371 -0
  47. .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/routes.py +262 -0
  48. .mcp-venv/lib/python3.13/site-packages/mcp/server/auth/settings.py +42 -0
  49. .mcp-venv/lib/python3.13/site-packages/mcp/server/caching.py +85 -0
  50. .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